feat(subgraph): add link-only promotion system

This commit is contained in:
DrJKL
2026-05-10 12:25:49 -07:00
parent ca54877f9d
commit eb569d5f2e
94 changed files with 7717 additions and 6176 deletions

View File

@@ -393,31 +393,62 @@ export class SubgraphHelper {
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const serialized = window.app!.graph!.serialize()
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
const widgetEntries = (node.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] as [
string,
string
]
]
}
return []
})
const serializedNode = serialized.nodes.find(
(candidate) => String(candidate.id) === String(node.id)
)
const previewExposures = Array.isArray(
serializedNode?.properties?.previewExposures
)
? serializedNode.properties.previewExposures
: []
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]
)
const previewEntries = previewExposures.flatMap((entry) => {
if (
typeof entry === 'object' &&
entry !== null &&
'sourceNodeId' in entry &&
typeof entry.sourceNodeId === 'string' &&
'sourcePreviewName' in entry &&
typeof entry.sourcePreviewName === 'string'
) {
return [
[entry.sourceNodeId, entry.sourcePreviewName] as [
string,
string
]
]
}
return []
})
return {
hostNodeId: String(node.id),
promotedWidgets
promotedWidgets: [...widgetEntries, ...previewEntries]
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))

View File

@@ -27,7 +27,7 @@ export async function getPromotedWidgets(
// 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) => {
const widgetEntries = widgets.flatMap((widget) => {
if (
widget &&
typeof widget === 'object' &&
@@ -40,6 +40,29 @@ export async function getPromotedWidgets(
}
return []
})
const serialized = window.app!.graph!.serialize()
const serializedNode = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
const previewExposures = serializedNode?.properties?.previewExposures
const previewEntries = Array.isArray(previewExposures)
? previewExposures.flatMap((exposure) => {
if (
typeof exposure === 'object' &&
exposure !== null &&
'sourceNodeId' in exposure &&
typeof exposure.sourceNodeId === 'string' &&
'sourcePreviewName' in exposure &&
typeof exposure.sourcePreviewName === 'string'
) {
return [[exposure.sourceNodeId, exposure.sourcePreviewName]]
}
return []
})
: []
return [...widgetEntries, ...previewEntries]
}, nodeId)
return normalizePromotedWidgets(raw)
@@ -78,12 +101,6 @@ 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
}

View File

@@ -1,20 +1,149 @@
import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { assetPath } from '@e2e/fixtures/utils/paths'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
import {
getPromotedWidgetCount,
getPromotedWidgetNames,
getPromotedWidgets
} from '@e2e/fixtures/utils/promotedWidgets'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
interface MutableWorkflowNode {
id: number
pos?: [number, number]
widgets_values?: unknown[]
properties?: Record<string, unknown>
}
type MutableWorkflow = ComfyWorkflowJSON & {
last_node_id: number
nodes: MutableWorkflowNode[]
}
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>
}
function loadPrimitiveFanoutWorkflow(): MutableWorkflow {
return JSON.parse(
readFileSync(
assetPath('subgraphs/subgraph-with-link-and-proxied-primitive.json'),
'utf-8'
)
) as MutableWorkflow
}
function createPrimitiveFanoutMultiHostWorkflow(): ComfyWorkflowJSON {
const workflow = loadPrimitiveFanoutWorkflow()
const original = workflow.nodes.find((node) => node.id === 2)
if (!original) throw new Error('Primitive fanout fixture is missing host 2')
original.widgets_values = ['first-host', 11]
const clone = structuredClone(original)
clone.id = 12
clone.pos = [900, 409]
clone.widgets_values = ['second-host', 22]
workflow.nodes.push(clone)
workflow.last_node_id = Math.max(workflow.last_node_id, clone.id)
return workflow
}
function createUnresolvableProxyWorkflow(): ComfyWorkflowJSON {
const workflow = loadPrimitiveFanoutWorkflow()
const host = workflow.nodes.find((node) => node.id === 2)
if (!host) throw new Error('Primitive fanout fixture is missing host 2')
host.properties = {
...host.properties,
proxyWidgets: [['9999', 'missing_widget']]
}
host.widgets_values = ['quarantined-host-value']
return workflow
}
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.getNodeById(4)
const primitiveOriginLinkCount = [
...hostNode.subgraph._links.values()
].filter((link) => link.origin_id === 4).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,
hostSubgraphNodeId: string,
@@ -41,6 +170,160 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.toBeGreaterThan(1)
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()
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', async ({
comfyPage
}) => {
await comfyPage.workflow.loadGraphData(
createPrimitiveFanoutMultiHostWorkflow()
)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.toBeGreaterThan(1)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '12'))
.toBeGreaterThan(1)
const firstHost = await getPrimitiveFanoutSnapshot(comfyPage, '2')
const secondHost = await getPrimitiveFanoutSnapshot(comfyPage, '12')
expect(
firstHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe('first-host')
expect(
firstHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe('first-host')
expect(
secondHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe('second-host')
expect(
secondHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe('second-host')
await comfyPage.subgraph.serializeAndReload()
const firstAfterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
const secondAfterReload = await getPrimitiveFanoutSnapshot(comfyPage, '12')
expect(
firstAfterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe('first-host')
expect(
firstAfterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe('first-host')
expect(
secondAfterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe('second-host')
expect(
secondAfterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe('second-host')
})
test('Nested preview exposures render through serialized chain resolution', async ({
comfyPage
}) => {
test.setTimeout(45_000)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.vueNodes.waitForNodes()
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
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
.toContain('$$canvas-image-preview')
// A host whose only promoted content is a preview exposure has no
// node.widgets entries and renders no `.lg-node-widgets` container; the
// pseudo-widget surfaces via usePromotedPreviews instead.
})
test('Legacy unresolvable proxy entry is omitted and quarantined on save', async ({
comfyPage
}) => {
await comfyPage.workflow.loadGraphData(createUnresolvableProxyWorkflow())
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', async ({
comfyPage
}) => {
@@ -487,14 +770,15 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// The legacy `proxyWidgets` entry references an interior nodeId that
// doesn't match the existing linked input's PromotedWidgetView source,
// so migration creates a second SubgraphInput rather than deduping.
// The intent of this test is that no legacy "<id>: <id>:" prefix
// leaks into the rendered widget rows.
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

@@ -93,12 +93,11 @@ 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')
// Hosts whose only promoted content is preview exposures have empty
// node.widgets, so the `.lg-node-widgets` container is not rendered at
// all (gated by `<NodeWidgets v-if="nodeData.widgets?.length">`). The
// assertion above (count by name returns the right number) already
// proves previews don't render as regular widget rows.
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

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

@@ -29,6 +29,7 @@ import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { cn } from '@comfyorg/tailwind-utils'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -157,10 +158,12 @@ 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 isPromoted = isPromotedWidgetView(widget)
const storeId =
isPromoted && app.rootGraph?.id
? createNodeLocatorId(app.rootGraph.id, node.id)
: node.id
const storeName = widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
)

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { computed, onBeforeUnmount, provide, shallowRef, triggerRef } from 'vue'
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
import { useI18n } from 'vue-i18n'
@@ -63,6 +63,17 @@ useEventListener(
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
// `LGraph.trigger()` invokes `onTrigger` synchronously but does not dispatch
// on `events`, so chain through `onTrigger` to react to slot-label renames.
const previousOnTrigger = app.rootGraph.onTrigger
app.rootGraph.onTrigger = (event) => {
previousOnTrigger?.(event)
if (event.type === 'node:slot-label:changed') triggerRef(graphNodes)
}
onBeforeUnmount(() => {
if (app.rootGraph.onTrigger === undefined) return
app.rootGraph.onTrigger = previousOnTrigger
})
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value

View File

@@ -3,10 +3,10 @@ import { computed, inject, 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 { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -70,8 +70,6 @@ const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
const promotionStore = usePromotionStore()
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
@@ -83,13 +81,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

@@ -14,13 +14,17 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputAtIndex
} 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 FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.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 +37,6 @@ const { node } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
@@ -55,9 +58,31 @@ 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 +106,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 +121,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)
})
)
})
@@ -190,12 +182,7 @@ function setDraggableState() {
this.draggableItem as HTMLElement
)
promotionStore.movePromotion(
node.rootGraph.id,
node.id,
oldPosition,
newPosition
)
reorderSubgraphInputAtIndex(node, oldPosition, newPosition)
canvasStore.canvas?.setDirty(true, true)
}
}

View File

@@ -9,9 +9,7 @@ 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(() => ({
@@ -201,64 +199,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

@@ -0,0 +1,324 @@
import userEvent from '@testing-library/user-event'
import { render, screen, within } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
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 { graphToPrompt } from '@/utils/executionUtil'
import {
getSourceNodeId,
promoteValueWidgetViaSubgraphInput
} from '@/core/graph/subgraph/promotionUtils'
import SubgraphEditor from './SubgraphEditor.vue'
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('reorders node widgets from dragged promoted input order when widget names repeat', 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('seed', 'STRING')
const firstWidget = firstNode.addWidget('text', 'seed', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('seed', 'STRING')
const secondWidget = secondNode.addWidget('text', 'seed', '', () => {})
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: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<button
data-testid="reverse-promoted-widgets"
@click="$emit('update:modelValue', [...modelValue].reverse())"
>
<slot drag-class="draggable-item" />
</button>
`
}
}
}
})
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
String(firstNode.id),
String(secondNode.id)
])
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
})
it('rerenders promoted widgets in dragged input order', 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: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<button
data-testid="reverse-promoted-widgets"
@click="$emit('update:modelValue', [...modelValue].reverse())"
>
<slot drag-class="draggable-item" />
</button>
`
}
}
}
})
expect(
within(screen.getByTestId('subgraph-editor-shown-section'))
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
expect(
within(screen.getByTestId('subgraph-editor-shown-section'))
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['second', 'first'])
})
it('serializes promoted widget values in dragged input order', 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)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<button
data-testid="reverse-promoted-widgets"
@click="$emit('update:modelValue', [...modelValue].reverse())"
>
<slot drag-class="draggable-item" />
</button>
`
}
}
}
})
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
it('sends dragged text input values to the matching prompt targets', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
const graph = host.graph!
graph.add(host)
const firstNode = new LGraphNode('FirstNode')
firstNode.comfyClass = 'FirstNode'
const secondNode = new LGraphNode('SecondNode')
secondNode.comfyClass = 'SecondNode'
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'
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<button
data-testid="reverse-promoted-widgets"
@click="$emit('update:modelValue', [...modelValue].reverse())"
>
<slot drag-class="draggable-item" />
</button>
`
}
}
}
})
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
const { output } = await graphToPrompt(graph)
expect(output[`${host.id}:${firstNode.id}`].inputs.text).toBe('first value')
expect(output[`${host.id}:${secondNode.id}`].inputs.text).toBe(
'second value'
)
})
})

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, onMounted, ref } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -14,7 +13,8 @@ import {
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -22,23 +22,17 @@ import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.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()
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 inputOrderVersion = ref(0)
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
@@ -51,56 +45,71 @@ const activeWidgets = computed<WidgetItem[]>({
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 [...getActivePromotedWidgets(node), ...getActivePreviewWidgets(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()
updateActiveWidgets(value, activeWidgets.value)
}
})
const activePromotedWidgets = computed<WidgetItem[]>({
get() {
const node = activeNode.value
return node ? getActivePromotedWidgets(node) : []
},
set(value: WidgetItem[]) {
updateActiveWidgets(value, activePromotedWidgets.value)
}
})
function getActivePromotedWidgets(node: SubgraphNode): WidgetItem[] {
void inputOrderVersion.value
return node.widgets.flatMap((widget): WidgetItem[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [[sourceNode, widget]]
})
}
function getActivePreviewWidgets(node: SubgraphNode): WidgetItem[] {
const hostLocator = String(node.id)
return previewExposureStore
.getExposures(node.rootGraph.id, hostLocator)
.flatMap((exposure): WidgetItem[] => {
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
if (!sourceNode) return []
const widget = getPromotableWidgets(sourceNode).find(
(candidate) => candidate.name === exposure.sourcePreviewName
)
return widget ? [[sourceNode, widget]] : []
})
}
function updateActiveWidgets(value: WidgetItem[], currentItems: WidgetItem[]) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
const currentKeys = new Set(currentItems.map(toKey))
const nextKeys = new Set(value.map(toKey))
for (const item of value) {
if (!currentKeys.has(toKey(item))) promote(item)
}
for (const item of currentItems) {
if (!nextKeys.has(toKey(item))) demote(item)
}
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map(([, widget]) => widget)
)
inputOrderVersion.value += 1
}
refreshPromotedWidgetRendering()
}
const interiorWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
@@ -119,14 +128,8 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
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
})
(item: WidgetItem) =>
!activeWidgets.value.some((active) => toKey(active) === toKey(item))
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -155,6 +158,14 @@ const filteredActive = computed<WidgetItem[]>(() => {
)
})
const filteredActivePromoted = computed<WidgetItem[]>(() =>
filteredActive.value.filter(([, widget]) => isPromotedWidgetView(widget))
)
const filteredActivePreviews = computed<WidgetItem[]>(() =>
filteredActive.value.filter(([, widget]) => !isPromotedWidgetView(widget))
)
function refreshPromotedWidgetRendering() {
const node = activeNode.value
if (!node) return
@@ -259,9 +270,9 @@ onMounted(() => {
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
<DraggableList v-slot="{ dragClass }" v-model="activePromotedWidgets">
<SubgraphNodeWidget
v-for="[node, widget] in filteredActive"
v-for="[node, widget] in filteredActivePromoted"
:key="toKey([node, widget])"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="node.title"
@@ -271,6 +282,18 @@ onMounted(() => {
@toggle-visibility="demote([node, widget])"
/>
</DraggableList>
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
<SubgraphNodeWidget
v-for="[node, widget] in filteredActivePreviews"
:key="toKey([node, widget])"
class="bg-comfy-menu-bg"
:node-title="node.title"
:widget-name="widget.label || widget.name"
:is-physical="isItemLinked([node, widget])"
:is-draggable="false"
@toggle-visibility="demote([node, widget])"
/>
</div>
</div>
<div

View File

@@ -61,6 +61,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

@@ -16,7 +16,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', () => {
@@ -207,7 +206,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
'10',
'prompt',
'value',
undefined,
'value'
)
@@ -403,37 +401,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 }))
@@ -476,118 +443,6 @@ describe('Nested promoted widget mapping', () => {
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', 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 independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget(
'text',
'string_a',
'independent',
() => undefined,
{}
)
subgraph.add(independentNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
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'
)
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}`
])
)
})
})
describe('Promoted widget sourceExecutionId', () => {

View File

@@ -12,6 +12,7 @@ import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -227,18 +228,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
}
}
@@ -256,10 +254,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
@@ -306,8 +303,7 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
@@ -320,11 +316,7 @@ 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
@@ -382,6 +374,11 @@ function buildSlotMetadata(
inputs?.forEach((input, index) => {
let originNodeId: string | undefined
let originOutputName: string | undefined
// Promotion via SubgraphInput materialises a real link from the
// SUBGRAPH_INPUT sentinel into the interior widget's input slot.
// That link is internal plumbing — not an external connection — so
// exclude it from `linked` (which downstream renders as disabled).
let isPromotionLink = false
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
@@ -389,12 +386,13 @@ function buildSlotMetadata(
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
isPromotionLink = link.origin_id === SUBGRAPH_INPUT_ID
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
linked: input.link != null && !isPromotionLink,
originNodeId,
originOutputName,
type: String(input.type)

View File

@@ -9,19 +9,27 @@ 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 { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
| 'nodeOutputs'
| 'nodeOutputsByExecutionId'
| 'nodePreviewImages'
| 'nodePreviewImagesByExecutionId'
| 'getNodeImageUrls'
| 'getNodeImageUrlsByExecutionId'
>
const getNodeImageUrls = vi.hoisted(() =>
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
)
const getNodeImageUrlsByExecutionId = vi.hoisted(() =>
vi.fn<MockNodeOutputStore['getNodeImageUrlsByExecutionId']>()
)
const useNodeOutputStoreMock = vi.hoisted(() =>
vi.fn<() => MockNodeOutputStore>()
)
@@ -35,8 +43,15 @@ vi.mock('@/stores/nodeOutputStore', () => {
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodeOutputsByExecutionId: reactive<
MockNodeOutputStore['nodeOutputsByExecutionId']
>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
nodePreviewImagesByExecutionId: reactive<
MockNodeOutputStore['nodePreviewImagesByExecutionId']
>({}),
getNodeImageUrls,
getNodeImageUrlsByExecutionId
}
}
@@ -83,6 +98,18 @@ function seedPreviewImages(
}
}
function exposePreview(
setup: ReturnType<typeof createSetup>,
sourceNodeId: string,
sourcePreviewName = '$$canvas-image-preview'
) {
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
String(setup.subgraphNode.id),
{ sourceNodeId, sourcePreviewName }
)
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
@@ -90,6 +117,7 @@ describe(usePromotedPreviews, () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
getNodeImageUrlsByExecutionId.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
@@ -109,11 +137,6 @@ 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([])
@@ -122,11 +145,7 @@ describe(usePromotedPreviews, () => {
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' }
)
exposePreview(setup, '10')
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
@@ -143,14 +162,41 @@ describe(usePromotedPreviews, () => {
])
})
it('migrates direct legacy host exposure keys while reading previews', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
const store = usePreviewExposureStore()
const rootGraphId = setup.subgraphNode.rootGraph.id
store.addExposure(
rootGraphId,
createNodeLocatorId(rootGraphId, setup.subgraphNode.id),
{
sourceNodeId: '10',
sourcePreviewName: '$$canvas-image-preview'
}
)
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)
expect(
store.getExposures(rootGraphId, String(setup.subgraphNode.id))
).toEqual([
expect.objectContaining({
sourceNodeId: '10',
sourcePreviewName: '$$canvas-image-preview'
})
])
})
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' }
)
exposePreview(setup, '10')
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
@@ -162,11 +208,7 @@ describe(usePromotedPreviews, () => {
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' }
)
exposePreview(setup, '10')
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
@@ -185,16 +227,8 @@ 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) => {
@@ -212,11 +246,7 @@ 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] }])
@@ -236,11 +266,7 @@ 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([])
@@ -262,11 +288,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,29 +296,16 @@ 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', () => {
it('uses preview exposures by source preview name', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
@@ -306,4 +315,169 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toHaveLength(1)
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
})
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'
}
)
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(outerSetup.subgraphNode.id),
{
sourceNodeId: String(innerHost.id),
sourcePreviewName: '$$canvas-image-preview'
}
)
const mockUrls = ['/view?filename=leaf.png']
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
getNodeImageUrls.mockImplementation((node: LGraphNode) =>
node === leafNode ? mockUrls : []
)
const { promotedPreviews } = usePromotedPreviews(
() => outerSetup.subgraphNode
)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: mockUrls
}
])
})
it('migrates nested legacy host exposure keys while resolving leaf media', () => {
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 rootGraphId = outerSetup.subgraphNode.rootGraph.id
const store = usePreviewExposureStore()
store.addExposure(
rootGraphId,
createNodeLocatorId(rootGraphId, innerHost.id),
{
sourceNodeId: String(leafNode.id),
sourcePreviewName: '$$canvas-image-preview'
}
)
store.addExposure(rootGraphId, String(outerSetup.subgraphNode.id), {
sourceNodeId: String(innerHost.id),
sourcePreviewName: '$$canvas-image-preview'
})
const mockUrls = ['/view?filename=leaf.png']
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
getNodeImageUrls.mockImplementation((node: LGraphNode) =>
node === leafNode ? mockUrls : []
)
const { promotedPreviews } = usePromotedPreviews(
() => outerSetup.subgraphNode
)
const nestedHostLocator = `${String(outerSetup.subgraphNode.id)}:${innerHost.id}`
expect(promotedPreviews.value).toHaveLength(1)
expect(store.getExposures(rootGraphId, nestedHostLocator)).toEqual([
expect.objectContaining({
sourceNodeId: '10',
sourcePreviewName: '$$canvas-image-preview'
})
])
})
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'
})
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
nodeOutputStore.nodePreviewImagesByExecutionId[firstLeafExecutionId] = [
'blob:first'
]
nodeOutputStore.nodePreviewImagesByExecutionId[secondLeafExecutionId] = [
'blob:second'
]
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',
type: 'image',
urls: ['blob:first']
}
]
)
expect(
usePromotedPreviews(() => secondHost).promotedPreviews.value
).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: ['blob:second']
}
])
})
})

View File

@@ -4,41 +4,128 @@ import { computed, toValue } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
interface PromotedPreview {
/** Source node id resolved on the host's interior subgraph. */
sourceNodeId: string
/** Canonical preview name on the source widget (typically `$$`-prefixed). */
sourceWidgetName: string
type: 'image' | 'video' | 'audio'
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.
* Returns reactive preview media exposed by a host SubgraphNode.
*
* Reads from the host-scoped {@link usePreviewExposureStore}, the canonical
* post-ADR-0009 source for display-only preview promotion.
*/
export function usePromotedPreviews(
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
) {
const promotionStore = usePromotionStore()
const previewExposureStore = usePreviewExposureStore()
const nodeOutputStore = useNodeOutputStore()
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 legacyHostLocator = createNodeLocatorId(rootGraphId, node.id)
const instanceExposures = previewExposureStore.getExposures(
rootGraphId,
hostLocator
)
if (!pseudoEntries.length) return []
let exposures = instanceExposures
if (!exposures.length) {
const legacyExposures = previewExposureStore.getExposures(
rootGraphId,
legacyHostLocator
)
if (legacyExposures.length) {
previewExposureStore.setExposures(
rootGraphId,
hostLocator,
legacyExposures
)
exposures = legacyExposures
}
}
const exposurePairs = exposures.map((exposure) => ({
exposureName: exposure.name,
sourceNodeId: exposure.sourceNodeId,
sourceWidgetName: exposure.sourcePreviewName
}))
if (!exposurePairs.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)
const resolveNestedHost = (
rootGraphId: UUID,
currentHostLocator: string,
sourceNodeId: string
) => {
const currentHost = hostNodesByLocator.get(currentHostLocator)
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
if (!(sourceNode instanceof SubgraphNode)) return undefined
const nestedHostLocator = `${currentHostLocator}:${sourceNode.id}`
const legacyNestedHostLocator = createNodeLocatorId(
rootGraphId,
sourceNode.id
)
const nestedExposures = previewExposureStore.getExposures(
rootGraphId,
nestedHostLocator
)
if (!nestedExposures.length) {
const definitionExposures = previewExposureStore.getExposures(
rootGraphId,
String(sourceNode.id)
)
const legacyExposures = definitionExposures.length
? definitionExposures
: previewExposureStore.getExposures(
rootGraphId,
legacyNestedHostLocator
)
if (legacyExposures.length) {
previewExposureStore.setExposures(
rootGraphId,
nestedHostLocator,
legacyExposures
)
}
}
hostNodesByLocator.set(nestedHostLocator, sourceNode)
return { rootGraphId, hostNodeLocator: nestedHostLocator }
}
for (const pair of exposurePairs) {
const resolved = previewExposureStore.resolveChain(
rootGraphId,
hostLocator,
pair.exposureName,
resolveNestedHost
)
const leaf = resolved?.leaf ?? {
sourceNodeId: pair.sourceNodeId,
sourcePreviewName: pair.sourceWidgetName
}
const leafHostLocator =
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
if (!interiorNode) continue
// Read from both reactive refs to establish Vue dependency
@@ -46,15 +133,29 @@ export function usePromotedPreviews(
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.sourceNodeId
leafHost.subgraph.id,
leaf.sourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
const leafExecutionId = `${leafHostLocator}:${leaf.sourceNodeId}`
const reactiveExecutionOutputs =
nodeOutputStore.nodeOutputsByExecutionId?.[leafExecutionId]
const reactiveExecutionPreviews =
nodeOutputStore.nodePreviewImagesByExecutionId?.[leafExecutionId]
if (
!reactiveOutputs?.images?.length &&
!reactivePreviews?.length &&
!reactiveExecutionOutputs?.images?.length &&
!reactiveExecutionPreviews?.length
)
continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
const urls =
nodeOutputStore.getNodeImageUrlsByExecutionId?.(
leafExecutionId,
interiorNode
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue
const type =
@@ -65,8 +166,8 @@ export function usePromotedPreviews(
: 'image'
previews.push({
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
sourceNodeId: leaf.sourceNodeId,
sourceWidgetName: leaf.sourcePreviewName,
type,
urls
})

View File

@@ -105,7 +105,11 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('returns original entry when prefix cannot be resolved', () => {
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
// ADR 0009: each SubgraphNode is opaque, so legacy nested
// disambiguator-based lookup no longer reaches deep widgets. The
// prefix is preserved as `disambiguatingSourceNodeId` lookup metadata
// for migration tooling.
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
@@ -116,7 +120,8 @@ describe('normalizeLegacyProxyWidgetEntry', () => {
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: '999: nonexistent_widget'
sourceWidgetName: 'nonexistent_widget',
disambiguatingSourceNodeId: '999'
})
})
})

View File

@@ -1,89 +1,54 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LegacyProxyEntrySource } 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
widgetName: string
): boolean {
return (
resolveConcretePromotedWidget(
hostNode,
sourceNodeId,
widgetName,
disambiguator
).status === 'resolved'
resolveConcretePromotedWidget(hostNode, sourceNodeId, widgetName).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 })
}
interface StrippedPrefix {
sourceWidgetName: string
/** Deepest legacy `n: ` prefix removed from the original widget name. */
deepestPrefixId?: string
}
function resolveLegacyPrefixedEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetPatch | undefined {
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 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
}
if (!match) return { sourceWidgetName: remaining, deepestPrefixId }
deepestPrefixId = match[1]
remaining = match[2]
}
}
/**
* Normalize a legacy `proxyWidgets` entry.
*
* Under ADR 0009 each `SubgraphNode` is opaque, so the canonical state never
* resolves through deep nested identities. This helper still recognizes the
* legacy `"<id>: <name>"` prefix encoding and surfaces the deepest prefix as
* `disambiguatingSourceNodeId` so migration tooling can preserve it as
* lookup metadata. The bare entry is returned unchanged when it already
* resolves at the immediate level.
*/
export function normalizeLegacyProxyWidgetEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetSource {
if (
canResolve(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
) {
): LegacyProxyEntrySource {
if (canResolve(hostNode, sourceNodeId, sourceWidgetName)) {
return {
sourceNodeId,
sourceWidgetName,
@@ -91,19 +56,13 @@ export function normalizeLegacyProxyWidgetEntry(
}
}
const patch = resolveLegacyPrefixedEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
const stripped = stripLegacyPrefixes(sourceWidgetName)
const patchDisambiguatingSourceNodeId =
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
sourceWidgetName: stripped.sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
})

View File

@@ -0,0 +1,313 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry'
import type {
LegacyProxyEntrySource,
PromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetTypes'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const graph = hostNode.graph!
graph.add(hostNode)
return hostNode
}
function makeSource(
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): LegacyProxyEntrySource {
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
}
describe(classifyProxyEntry, () => {
describe('alreadyLinked branch', () => {
it('returns alreadyLinked when an input already represents the entry', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const inputSlot = host.addInput('seed_link', '*')
inputSlot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
const normalized = makeSource(String(innerNode.id), 'seed')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result.classification).toBe('value')
expect(result.plan).toEqual({
kind: 'alreadyLinked',
subgraphInputName: 'seed_link'
})
})
it('quarantines as ambiguous when canonical inputs share the same identity, even if the legacy entry has a disambiguator', () => {
// ADR 0009: canonical PromotedWidgetView no longer carries a
// `disambiguatingSourceNodeId`, so two inputs sharing the same
// (sourceNodeId, sourceWidgetName) cannot be told apart by the
// classifier. The legacy entry's disambiguator is metadata only and
// does not break the tie.
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
for (const inputName of ['first_seed', 'second_seed']) {
const input = host.addInput(inputName, '*')
input._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
}
const normalized = makeSource(String(innerNode.id), 'seed', 'second')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result).toEqual({
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
})
})
it('quarantines ambiguous already-linked inputs without a disambiguator', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
for (const inputName of ['first_seed', 'second_seed']) {
const input = host.addInput(inputName, '*')
input._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
}
const normalized = makeSource(String(innerNode.id), 'seed')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result).toEqual({
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
})
})
})
describe('quarantine branches', () => {
it('quarantines when source node is missing', () => {
const host = buildHost()
const normalized = makeSource('999', 'seed')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result).toEqual({
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
})
})
it('quarantines when source widget is missing on the source node', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
host.subgraph.add(innerNode)
const normalized = makeSource(String(innerNode.id), 'nonexistent')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result).toEqual({
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'missingSourceWidget' }
})
})
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)
const normalized = makeSource(String(primitive.id), 'value')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result).toEqual({
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
})
})
})
describe('preview branch', () => {
it('classifies $$-prefixed names as preview exposure', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
const normalized = makeSource(
String(innerNode.id),
'$$canvas-image-preview'
)
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result.classification).toBe('preview')
expect(result.plan).toEqual({
kind: 'previewExposure',
sourcePreviewName: '$$canvas-image-preview'
})
})
it('classifies type:preview serialize:false widgets as preview exposure', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
const widget = innerNode.addWidget('text', 'videopreview', '', () => {})
widget.type = 'preview'
widget.serialize = false
host.subgraph.add(innerNode)
const normalized = makeSource(String(innerNode.id), 'videopreview')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result.classification).toBe('preview')
expect(result.plan).toEqual({
kind: 'previewExposure',
sourcePreviewName: 'videopreview'
})
})
})
describe('value-widget branch', () => {
it('plans a createSubgraphInput when the widget exists and is not linked', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 42, () => {})
host.subgraph.add(innerNode)
const normalized = makeSource(String(innerNode.id), 'seed')
const result = classifyProxyEntry({
hostNode: host,
normalized,
cohort: [normalized]
})
expect(result).toEqual({
classification: 'value',
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
})
})
})
describe('primitive fanout branch', () => {
it('emits primitiveBypass with target list when cohort points at the same primitive', () => {
const host = buildHost()
const primitive = new LGraphNode('Primitive')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', 'INT')
host.subgraph.add(primitive)
const targetA = new LGraphNode('TargetA')
targetA.addInput('value', 'INT')
targetA.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(targetA)
const targetB = new LGraphNode('TargetB')
targetB.addInput('value', 'INT')
targetB.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(targetB)
primitive.connect(0, targetA, 0)
primitive.connect(0, targetB, 0)
const sourceA = makeSource(String(primitive.id), 'seed')
// Cohort has 2 entries pointing at the primitive (one per target).
const cohort = [sourceA, sourceA]
const result = classifyProxyEntry({
hostNode: host,
normalized: sourceA,
cohort
})
expect(result.classification).toBe('primitiveFanout')
expect(result.plan.kind).toBe('primitiveBypass')
if (result.plan.kind !== 'primitiveBypass') return
expect(result.plan.primitiveNodeId).toBe(primitive.id)
expect(result.plan.sourceWidgetName).toBe('seed')
expect(result.plan.targets).toHaveLength(2)
expect(result.plan.targets.map((t) => t.targetNodeId)).toEqual(
expect.arrayContaining([targetA.id, targetB.id])
)
})
})
})

View File

@@ -0,0 +1,168 @@
import type {
MigrationPlan,
PrimitiveBypassTargetRef,
ProxyEntryClassification
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getPromotableWidgets,
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
interface ClassificationResult {
classification: ProxyEntryClassification
plan: MigrationPlan
}
interface ClassifyProxyEntryArgs {
hostNode: SubgraphNode
normalized: LegacyProxyEntrySource
/** All proxy entries this planner pass is considering — needed to detect primitive fan-out. */
cohort: readonly LegacyProxyEntrySource[]
}
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
type LinkedInputMatch =
| { kind: 'none' }
| { kind: 'one'; inputName: string }
| { kind: 'ambiguous' }
function findLinkedSubgraphInputMatch(
hostNode: SubgraphNode,
normalized: LegacyProxyEntrySource
): LinkedInputMatch {
const matches: string[] = []
for (const input of hostNode.inputs) {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) continue
if (
widget.sourceNodeId === normalized.sourceNodeId &&
widget.sourceWidgetName === normalized.sourceWidgetName
) {
matches.push(input.name)
}
}
if (matches.length === 0) return { kind: 'none' }
if (matches.length === 1) return { kind: 'one', inputName: matches[0] }
return { kind: 'ambiguous' }
}
function collectPrimitiveTargets(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] {
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) continue
targets.push({
targetNodeId: link.target_id,
targetSlot: link.target_slot
})
}
return targets
}
function cohortReferencesPrimitive(
cohort: readonly LegacyProxyEntrySource[],
primitiveNodeId: string
): boolean {
let count = 0
for (const entry of cohort) {
if (entry.sourceNodeId === primitiveNodeId) {
count += 1
if (count >= 2) return true
}
}
return false
}
export function classifyProxyEntry(
args: ClassifyProxyEntryArgs
): ClassificationResult {
const { hostNode, normalized, cohort } = args
const linkedInput = findLinkedSubgraphInputMatch(hostNode, normalized)
if (linkedInput.kind === 'one') {
return {
classification: 'value',
plan: { kind: 'alreadyLinked', subgraphInputName: linkedInput.inputName }
}
}
if (linkedInput.kind === 'ambiguous') {
return {
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
}
const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId)
if (!sourceNode) {
return {
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
}
}
if (sourceNode.type === PRIMITIVE_NODE_TYPE) {
const targets = collectPrimitiveTargets(hostNode, sourceNode)
const cohortDuplicated = cohortReferencesPrimitive(
cohort,
normalized.sourceNodeId
)
if (targets.length >= 1 || cohortDuplicated) {
return {
classification: 'primitiveFanout',
plan: {
kind: 'primitiveBypass',
primitiveNodeId: sourceNode.id,
sourceWidgetName: normalized.sourceWidgetName,
targets
}
}
}
return {
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
}
}
const promotableWidgets = getPromotableWidgets(sourceNode)
const sourceWidget = promotableWidgets.find(
(w) => w.name === normalized.sourceWidgetName
)
if (!sourceWidget) {
return {
classification: 'unknown',
plan: { kind: 'quarantine', reason: 'missingSourceWidget' }
}
}
if (
normalized.sourceWidgetName.startsWith('$$') ||
isPreviewPseudoWidget(sourceWidget)
) {
return {
classification: 'preview',
plan: {
kind: 'previewExposure',
sourcePreviewName: normalized.sourceWidgetName
}
}
}
return {
classification: 'value',
plan: {
kind: 'createSubgraphInput',
sourceWidgetName: normalized.sourceWidgetName
}
}
}

View File

@@ -0,0 +1,223 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { migratePreviewExposure } from '@/core/graph/subgraph/migration/migratePreviewExposure'
import type { ResolveNestedHostFn } from '@/stores/previewExposureStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.graph!.add(hostNode)
return hostNode
}
function buildEntry(args: {
sourceNodeId: string
sourcePreviewName: string
}): PendingMigrationEntry {
return {
normalized: {
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourcePreviewName
},
legacyOrderIndex: 0,
hostValue: HOST_VALUE_HOLE,
plan: {
kind: 'previewExposure',
sourcePreviewName: args.sourcePreviewName
}
}
}
describe(migratePreviewExposure, () => {
it('adds an exposure for a $$-prefixed preview source', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
host.subgraph.add(innerNode)
const store = usePreviewExposureStore()
const result = migratePreviewExposure({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourcePreviewName: '$$canvas-image-preview'
}),
store
})
expect(result).toEqual({
ok: true,
previewName: '$$canvas-image-preview'
})
const locator = String(host.id)
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
})
it('produces a unique name on collision via nextUniqueName', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
host.subgraph.add(innerNode)
const otherInner = new LGraphNode('OtherInner')
host.subgraph.add(otherInner)
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(innerNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
const result = migratePreviewExposure({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(otherInner.id),
sourcePreviewName: '$$canvas-image-preview'
}),
store
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.previewName).toBe('$$canvas-image-preview_1')
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(2)
})
it('reuses an existing exposure for the same source preview', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
host.subgraph.add(innerNode)
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(innerNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
const result = migratePreviewExposure({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourcePreviewName: '$$canvas-image-preview'
}),
store
})
expect(result).toEqual({
ok: true,
previewName: '$$canvas-image-preview'
})
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
})
it('returns missingSourceNode when the source node is absent', () => {
const host = buildHost()
const store = usePreviewExposureStore()
const result = migratePreviewExposure({
hostNode: host,
entry: buildEntry({
sourceNodeId: '999',
sourcePreviewName: '$$canvas-image-preview'
}),
store
})
expect(result).toEqual({ ok: false, reason: 'missingSourceNode' })
})
it('round-trips through resolveChain across an outer host into an inner host', () => {
// Set up an inner host with a leaf preview exposure, and a separate outer
// host whose interior contains a placeholder for the inner host. The
// chain walker is graph-agnostic, so we wire the nested-host edge via
// the resolver callback.
const innerSubgraph = createTestSubgraph({ name: 'Inner' })
const innerHost = createTestSubgraphNode(innerSubgraph)
innerHost.graph!.add(innerHost)
const innerLeaf = new LGraphNode('Leaf')
innerSubgraph.add(innerLeaf)
const outerSubgraph = createTestSubgraph({ name: 'Outer' })
const outerHost = createTestSubgraphNode(outerSubgraph)
outerHost.graph!.add(outerHost)
const placeholder = new LGraphNode('PlaceholderInnerHost')
outerSubgraph.add(placeholder)
const store = usePreviewExposureStore()
const innerLocator = String(innerHost.id)
const outerLocator = String(outerHost.id)
// Inner host: the leaf exposure (canonical $$ name) the outer chain
// ultimately resolves to.
store.addExposure(innerHost.rootGraph.id, innerLocator, {
sourceNodeId: String(innerLeaf.id),
sourcePreviewName: '$$inner-preview'
})
// Outer host: migrate an entry whose source points at the placeholder
// (representing the inner host inside outer's interior).
const result = migratePreviewExposure({
hostNode: outerHost,
entry: {
normalized: {
sourceNodeId: String(placeholder.id),
sourceWidgetName: '$$inner-preview'
},
legacyOrderIndex: 0,
hostValue: HOST_VALUE_HOLE,
plan: {
kind: 'previewExposure',
sourcePreviewName: '$$inner-preview'
}
},
store
})
expect(result.ok).toBe(true)
const resolveNestedHost: ResolveNestedHostFn = (
_rootGraphId,
_hostLocator,
sourceNodeId
) =>
sourceNodeId === String(placeholder.id)
? { rootGraphId: innerHost.rootGraph.id, hostNodeLocator: innerLocator }
: undefined
const chain = store.resolveChain(
outerHost.rootGraph.id,
outerLocator,
'$$inner-preview',
resolveNestedHost
)
expect(chain).toBeDefined()
expect(chain?.steps).toHaveLength(2)
expect(chain?.leaf.sourceNodeId).toBe(String(innerLeaf.id))
expect(chain?.leaf.sourcePreviewName).toBe('$$inner-preview')
})
})

View File

@@ -0,0 +1,68 @@
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { usePreviewExposureStore } from '@/stores/previewExposureStore'
type MigratePreviewExposureResult =
| { ok: true; previewName: string }
| { ok: false; reason: 'missingSourceNode' | 'missingSourceWidget' }
interface MigratePreviewExposureArgs {
hostNode: SubgraphNode
entry: PendingMigrationEntry
/** Pinia store action — pass `usePreviewExposureStore()` from the caller. */
store: ReturnType<typeof usePreviewExposureStore>
}
/**
* Project a single legacy preview-shaped proxy entry into the host-scoped
* {@link usePreviewExposureStore}.
*
* For canonical `$$`-prefixed preview names the source widget may be lazily
* created at first execution; we treat the exposure as metadata-only and do
* not require the concrete widget to be present yet. For non-`$$` previews
* (e.g. `videopreview`) the widget must already exist on the source node.
*/
export function migratePreviewExposure(
args: MigratePreviewExposureArgs
): MigratePreviewExposureResult {
const { hostNode, entry, store } = args
const { plan } = entry
if (plan.kind !== 'previewExposure') {
throw new Error(`migratePreviewExposure: invalid plan kind ${plan.kind}`)
}
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 }
}

View File

@@ -0,0 +1,235 @@
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 { setSubgraphMigrationFlushHook } from '@/lib/litegraph/src/subgraph/subgraphMigrationHook'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry'
import { wireProxyWidgetMigrationFlush } from '@/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush'
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()
setSubgraphMigrationFlushHook(undefined)
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const graph = hostNode.graph!
graph.add(hostNode)
return hostNode
}
describe(flushProxyWidgetMigration, () => {
it('returns an empty result when no proxyWidgets are present', () => {
const host = buildHost()
const result = flushProxyWidgetMigration({ hostNode: host })
expect(result).toEqual({
repaired: 0,
primitiveRepaired: 0,
previewMigrated: 0,
quarantined: 0
})
})
it('migrates a preview-shaped entry into the PreviewExposureStore', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), '$$canvas-image-preview']
]
const result = flushProxyWidgetMigration({ hostNode: host })
expect(result.previewMigrated).toBe(1)
expect(result.quarantined).toBe(0)
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
})
it('quarantines entries whose source node has disappeared', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
const result = flushProxyWidgetMigration({ hostNode: host })
expect(result.quarantined).toBe(1)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'seed'],
reason: 'missingSourceNode'
})
])
})
it('counts already-linked entries as repaired and applies the host value', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const inputSlot = host.addInput('seed_link', '*')
let widgetValue: TWidgetValue = 0
inputSlot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
}
})
host.properties.proxyWidgets = [[String(innerNode.id), 'seed']]
const result = flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(result.repaired).toBe(1)
expect(result.quarantined).toBe(0)
expect(widgetValue).toBe(99)
})
it('clears properties.proxyWidgets after a successful flush', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('runs through LGraph.configure when the flush hook is wired', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), '$$canvas-image-preview']
]
const serialized = host.rootGraph.serialize()
wireProxyWidgetMigrationFlush()
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(innerNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
])
})
describe('idempotency', () => {
it('re-running flush over a fully migrated host produces no further mutations', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), '$$canvas-image-preview']
]
const first = flushProxyWidgetMigration({ hostNode: host })
expect(first.previewMigrated).toBe(1)
const exposuresAfterFirst = usePreviewExposureStore()
.getExposures(host.rootGraph.id, String(host.id))
.map((e) => ({ ...e }))
const second = flushProxyWidgetMigration({ hostNode: host })
expect(second).toEqual({
repaired: 0,
primitiveRepaired: 0,
previewMigrated: 0,
quarantined: 0
})
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
).toEqual(exposuresAfterFirst)
})
it('re-running flush over a quarantined host does not duplicate quarantine entries', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
const firstQuarantine = readHostQuarantine(host)
expect(firstQuarantine).toHaveLength(1)
// Reseed proxyWidgets to simulate a stale legacy reload of the same
// unresolved entry; flush must still produce no duplicates.
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
})
})
})

View File

@@ -0,0 +1,166 @@
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { migratePreviewExposure } from '@/core/graph/subgraph/migration/migratePreviewExposure'
import { planProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanner'
import {
appendHostQuarantine,
makeQuarantineEntry
} from '@/core/graph/subgraph/migration/quarantineEntry'
import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout'
import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget'
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import type { ProxyWidgetErrorQuarantineEntry } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
interface FlushProxyWidgetMigrationArgs {
hostNode: SubgraphNode
/** widgets_values from the host node at parse time. May be sparse. */
hostWidgetValues?: readonly unknown[]
}
interface FlushProxyWidgetMigrationResult {
repaired: number
primitiveRepaired: number
previewMigrated: number
quarantined: number
}
const EMPTY_RESULT: FlushProxyWidgetMigrationResult = {
repaired: 0,
primitiveRepaired: 0,
previewMigrated: 0,
quarantined: 0
}
function toLegacyTuple(
source: LegacyProxyEntrySource
): SerializedProxyWidgetTuple {
return source.disambiguatingSourceNodeId
? [
source.sourceNodeId,
source.sourceWidgetName,
source.disambiguatingSourceNodeId
]
: [source.sourceNodeId, source.sourceWidgetName]
}
function unwrapHostValue(
hostValue: PendingMigrationEntry['hostValue']
): TWidgetValue | undefined {
return hostValue === HOST_VALUE_HOLE ? undefined : (hostValue as TWidgetValue)
}
function quarantineFor(
entry: PendingMigrationEntry,
reason: ProxyWidgetErrorQuarantineEntry['reason']
): ProxyWidgetErrorQuarantineEntry {
return makeQuarantineEntry({
originalEntry: toLegacyTuple(entry.normalized),
reason,
hostValue: unwrapHostValue(entry.hostValue)
})
}
/**
* Forward-ratchet a host SubgraphNode's legacy `properties.proxyWidgets` into
* canonical representations:
*
* - value-widget entries → linked SubgraphInput via {@link repairValueWidget};
* - primitive-fanout cohorts → one SubgraphInput per primitive via
* {@link repairPrimitiveFanout};
* - preview entries → host-scoped exposure via {@link migratePreviewExposure};
* - unrepairable / quarantine plans → appended to
* `properties.proxyWidgetErrorQuarantine`.
*
* Idempotent: re-running flush over an already-migrated host produces no
* mutations and no duplicates because (a) the planner classifies migrated
* entries as `alreadyLinked` (a no-op apply), (b) preview/quarantine helpers
* dedup, and (c) the legacy `properties.proxyWidgets` is removed once flush
* succeeds so subsequent calls return early.
*/
export function flushProxyWidgetMigration(
args: FlushProxyWidgetMigrationArgs
): FlushProxyWidgetMigrationResult {
const { hostNode, hostWidgetValues } = args
const plan = planProxyWidgetMigration({ hostNode, hostWidgetValues })
if (plan.entries.length === 0) return EMPTY_RESULT
const previewStore = usePreviewExposureStore()
const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = []
const result: FlushProxyWidgetMigrationResult = { ...EMPTY_RESULT }
// Group primitive-bypass entries per primitive node. Cohort flushed
// all-or-nothing through repairPrimitiveFanout.
const primitiveCohorts = new Map<NodeId, PendingMigrationEntry[]>()
for (const entry of plan.entries) {
const { plan: planEntry } = entry
if (planEntry.kind === 'primitiveBypass') {
const cohort = primitiveCohorts.get(planEntry.primitiveNodeId) ?? []
cohort.push(entry)
primitiveCohorts.set(planEntry.primitiveNodeId, cohort)
continue
}
if (
planEntry.kind === 'alreadyLinked' ||
planEntry.kind === 'createSubgraphInput'
) {
const repair = repairValueWidget({ hostNode, entry })
if (repair.ok) {
result.repaired += 1
} else {
quarantineToAppend.push(quarantineFor(entry, repair.reason))
}
continue
}
if (planEntry.kind === 'previewExposure') {
const repair = migratePreviewExposure({
hostNode,
entry,
store: previewStore
})
if (repair.ok) {
result.previewMigrated += 1
} else {
quarantineToAppend.push(quarantineFor(entry, repair.reason))
}
continue
}
if (planEntry.kind === 'quarantine') {
quarantineToAppend.push(quarantineFor(entry, planEntry.reason))
}
}
for (const cohort of primitiveCohorts.values()) {
const repair = repairPrimitiveFanout({ hostNode, cohort })
if (repair.ok) {
result.primitiveRepaired += 1
} else {
for (const entry of cohort) {
quarantineToAppend.push(quarantineFor(entry, repair.reason))
}
}
}
if (quarantineToAppend.length > 0) {
appendHostQuarantine(hostNode, quarantineToAppend)
result.quarantined = quarantineToAppend.length
}
// Idempotency anchor: once entries have been processed, drop the legacy
// payload so subsequent loads/configures take the no-op short-circuit.
// Canonical state now lives on linked SubgraphInputs, the
// PreviewExposureStore, and properties.proxyWidgetErrorQuarantine.
delete hostNode.properties.proxyWidgets
return result
}

View File

@@ -0,0 +1,66 @@
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ProxyWidgetQuarantineReason } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
/**
* Sentinel marking a sparse hole in a `widgets_values` array. Distinct from
* `undefined` so that an explicitly-stored `undefined` host value can still be
* represented when needed.
*/
export const HOST_VALUE_HOLE = Symbol('proxyWidgetMigration.hostValueHole')
export type HostValueHole = typeof HOST_VALUE_HOLE
export type HostValue = TWidgetValue | HostValueHole
export type ProxyEntryClassification =
| 'value'
| 'preview'
| 'primitiveFanout'
| 'unknown'
export interface PrimitiveBypassTargetRef {
targetNodeId: NodeId
targetSlot: number
}
export type MigrationPlan =
| { 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 }
/**
* One pending migration entry produced by the planner.
*
* @remarks
* This is the input to the flush step. The planner does not mutate the graph;
* it walks legacy `properties.proxyWidgets` and `widgets_values`, classifies
* each entry, and emits a {@link PendingMigrationEntry} describing what the
* flush should do. Flush re-validates against the current graph before
* applying mutations.
*/
export interface PendingMigrationEntry {
normalized: LegacyProxyEntrySource
legacyOrderIndex: number
hostValue: HostValue
plan: MigrationPlan
}
/**
* The full plan the planner returns for a single host SubgraphNode.
*
* Entries are ordered by `legacyOrderIndex` ascending. Idempotency: re-running
* the planner over a host whose canonical state already represents an entry
* yields a `'alreadyLinked'`/`'previewExposure'` plan that the flush step
* treats as a no-op.
*/
export interface ProxyWidgetMigrationPlan {
entries: readonly PendingMigrationEntry[]
}

View File

@@ -0,0 +1,209 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { planProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanner'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const graph = hostNode.graph!
graph.add(hostNode)
return hostNode
}
function findEntry(
entries: readonly PendingMigrationEntry[],
index: number
): PendingMigrationEntry {
const entry = entries.find((e) => e.legacyOrderIndex === index)
if (!entry) throw new Error(`Expected entry at legacyOrderIndex ${index}`)
return entry
}
describe(planProxyWidgetMigration, () => {
it('returns an empty plan when properties.proxyWidgets is missing', () => {
const host = buildHost()
const plan = planProxyWidgetMigration({ hostNode: host })
expect(plan.entries).toEqual([])
})
it('tolerates a malformed proxyWidgets JSON string and returns empty', () => {
const host = buildHost()
host.properties.proxyWidgets = '{not json}'
const plan = planProxyWidgetMigration({ hostNode: host })
expect(plan.entries).toEqual([])
})
it('emits classified entries for a mixed value+preview cohort, preserving order', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), 'seed'],
[String(innerNode.id), '$$canvas-image-preview']
]
const plan = planProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(plan.entries).toHaveLength(2)
const valueEntry = findEntry(plan.entries, 0)
expect(valueEntry.plan).toEqual({
kind: 'createSubgraphInput',
sourceWidgetName: 'seed'
})
expect(valueEntry.hostValue).toBe(99)
const previewEntry = findEntry(plan.entries, 1)
expect(previewEntry.plan).toEqual({
kind: 'previewExposure',
sourcePreviewName: '$$canvas-image-preview'
})
expect(previewEntry.hostValue).toBe(HOST_VALUE_HOLE)
})
it('preserves sparse holes in widgets_values when they are missing', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'a', 0, () => {})
innerNode.addWidget('number', 'b', 0, () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), 'a'],
[String(innerNode.id), 'b']
]
const sparse: unknown[] = []
sparse[1] = 'second-value'
const plan = planProxyWidgetMigration({
hostNode: host,
hostWidgetValues: sparse
})
expect(findEntry(plan.entries, 0).hostValue).toBe(HOST_VALUE_HOLE)
expect(findEntry(plan.entries, 1).hostValue).toBe('second-value')
})
it('emits a primitiveBypass plan per cohort entry pointing at the same primitive', () => {
const host = buildHost()
const primitive = new LGraphNode('Primitive')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', 'INT')
host.subgraph.add(primitive)
const targetA = new LGraphNode('TargetA')
targetA.addInput('value', 'INT')
targetA.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(targetA)
const targetB = new LGraphNode('TargetB')
targetB.addInput('value', 'INT')
targetB.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(targetB)
primitive.connect(0, targetA, 0)
primitive.connect(0, targetB, 0)
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'value']
]
const plan = planProxyWidgetMigration({ hostNode: host })
expect(plan.entries).toHaveLength(2)
for (const entry of plan.entries) {
expect(entry.plan.kind).toBe('primitiveBypass')
if (entry.plan.kind !== 'primitiveBypass') continue
expect(entry.plan.primitiveNodeId).toBe(primitive.id)
expect(entry.plan.targets).toHaveLength(2)
}
})
it('is idempotent: re-running on a host whose entries are already linked yields alreadyLinked plans', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [[String(innerNode.id), 'seed']]
const firstPass = planProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [42]
})
expect(findEntry(firstPass.entries, 0).plan).toEqual({
kind: 'createSubgraphInput',
sourceWidgetName: 'seed'
})
// Simulate the flush step linking the input.
const inputSlot = host.addInput('seed', '*')
inputSlot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
const secondPass = planProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [42]
})
expect(secondPass.entries).toHaveLength(1)
expect(findEntry(secondPass.entries, 0).plan).toEqual({
kind: 'alreadyLinked',
subgraphInputName: 'seed'
})
})
it('quarantines entries pointing at missing source nodes', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
const plan = planProxyWidgetMigration({ hostNode: host })
expect(plan.entries).toHaveLength(1)
expect(findEntry(plan.entries, 0).plan).toEqual({
kind: 'quarantine',
reason: 'missingSourceNode'
})
})
})

View File

@@ -0,0 +1,69 @@
import { classifyProxyEntry } from '@/core/graph/subgraph/migration/classifyProxyEntry'
import type {
HostValue,
PendingMigrationEntry,
ProxyWidgetMigrationPlan
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import type { LegacyProxyEntrySource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
interface PlanProxyWidgetMigrationArgs {
hostNode: SubgraphNode
/** widgets_values from the host node at parse time. May be sparse. */
hostWidgetValues?: readonly unknown[]
}
function pickHostValue(
hostWidgetValues: readonly unknown[] | undefined,
index: number
): HostValue {
if (!hostWidgetValues) return HOST_VALUE_HOLE
if (index < 0 || index >= hostWidgetValues.length) return HOST_VALUE_HOLE
if (!Object.prototype.hasOwnProperty.call(hostWidgetValues, index)) {
return HOST_VALUE_HOLE
}
return hostWidgetValues[index] as TWidgetValue
}
export function planProxyWidgetMigration(
args: PlanProxyWidgetMigrationArgs
): ProxyWidgetMigrationPlan {
const { hostNode, hostWidgetValues } = args
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
if (tuples.length === 0) return { entries: [] }
const normalized: LegacyProxyEntrySource[] = tuples.map(
([sourceNodeId, sourceWidgetName, disambiguator]) =>
normalizeLegacyProxyWidgetEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguator
)
)
const entries: PendingMigrationEntry[] = normalized.map(
(entry, legacyOrderIndex) => {
const { plan } = classifyProxyEntry({
hostNode,
normalized: entry,
cohort: normalized
})
return {
normalized: entry,
legacyOrderIndex,
hostValue: pickHostValue(hostWidgetValues, legacyOrderIndex),
plan
}
}
)
entries.sort((a, b) => a.legacyOrderIndex - b.legacyOrderIndex)
return { entries }
}

View File

@@ -0,0 +1,149 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
appendHostQuarantine,
clearHostQuarantine,
makeQuarantineEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/quarantineEntry'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const graph = hostNode.graph!
graph.add(hostNode)
return hostNode
}
describe(makeQuarantineEntry, () => {
it('builds an entry with attemptedAtVersion pinned to 1', () => {
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
const entry = makeQuarantineEntry({
originalEntry: tuple,
reason: 'missingSourceNode'
})
expect(entry).toEqual({
originalEntry: tuple,
reason: 'missingSourceNode',
attemptedAtVersion: 1
})
})
it('includes hostValue when provided', () => {
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
const entry = makeQuarantineEntry({
originalEntry: tuple,
reason: 'missingSourceNode',
hostValue: 42
})
expect(entry.hostValue).toBe(42)
})
})
describe('host quarantine helpers', () => {
it('returns an empty array for an unconfigured host', () => {
const host = buildHost()
expect(readHostQuarantine(host)).toEqual([])
})
it('round-trips entries via append + read', () => {
const host = buildHost()
const entry = makeQuarantineEntry({
originalEntry: ['7', 'seed'],
reason: 'missingSourceWidget',
hostValue: 'preserved'
})
appendHostQuarantine(host, [entry])
expect(readHostQuarantine(host)).toEqual([entry])
})
it('deduplicates entries with identical originalEntry tuples', () => {
const host = buildHost()
const tuple: SerializedProxyWidgetTuple = ['7', 'seed']
const first = makeQuarantineEntry({
originalEntry: tuple,
reason: 'missingSourceWidget',
hostValue: 1
})
const duplicate = makeQuarantineEntry({
originalEntry: tuple,
reason: 'unlinkedSourceWidget',
hostValue: 2
})
appendHostQuarantine(host, [first])
appendHostQuarantine(host, [duplicate])
const stored = readHostQuarantine(host)
expect(stored).toHaveLength(1)
expect(stored[0]).toEqual(first)
})
it('keeps entries that differ by disambiguator in the originalEntry tuple', () => {
const host = buildHost()
const baseEntry = makeQuarantineEntry({
originalEntry: ['7', 'seed'],
reason: 'missingSourceWidget'
})
const disambiguatedEntry = makeQuarantineEntry({
originalEntry: ['7', 'seed', 'inner-leaf'],
reason: 'missingSourceWidget'
})
appendHostQuarantine(host, [baseEntry, disambiguatedEntry])
expect(readHostQuarantine(host)).toHaveLength(2)
})
it('clearHostQuarantine removes the property entirely', () => {
const host = buildHost()
appendHostQuarantine(host, [
makeQuarantineEntry({
originalEntry: ['7', 'seed'],
reason: 'missingSourceWidget'
})
])
clearHostQuarantine(host)
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(readHostQuarantine(host)).toEqual([])
})
it('appendHostQuarantine is a no-op when given an empty list', () => {
const host = buildHost()
appendHostQuarantine(host, [])
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
})

View File

@@ -0,0 +1,67 @@
import { isEqual } from 'es-toolkit/compat'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import type {
ProxyWidgetErrorQuarantineEntry,
ProxyWidgetQuarantineReason
} from '@/core/schemas/proxyWidgetQuarantineSchema'
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine'
const QUARANTINE_VERSION = 1
interface MakeQuarantineEntryArgs {
originalEntry: SerializedProxyWidgetTuple
reason: ProxyWidgetQuarantineReason
hostValue?: TWidgetValue
}
export function readHostQuarantine(
hostNode: SubgraphNode
): ProxyWidgetErrorQuarantineEntry[] {
return parseProxyWidgetErrorQuarantine(
hostNode.properties[QUARANTINE_PROPERTY]
)
}
export function makeQuarantineEntry(
args: MakeQuarantineEntryArgs
): ProxyWidgetErrorQuarantineEntry {
const entry: ProxyWidgetErrorQuarantineEntry = {
originalEntry: args.originalEntry,
reason: args.reason,
attemptedAtVersion: QUARANTINE_VERSION
}
if (args.hostValue !== undefined) {
entry.hostValue = args.hostValue
}
return entry
}
export function appendHostQuarantine(
hostNode: SubgraphNode,
entries: readonly ProxyWidgetErrorQuarantineEntry[]
): void {
if (entries.length === 0) return
const existing = readHostQuarantine(hostNode)
const merged = [...existing]
for (const candidate of entries) {
const isDuplicate = merged.some((existingEntry) =>
isEqual(existingEntry.originalEntry, candidate.originalEntry)
)
if (!isDuplicate) merged.push(candidate)
}
if (merged.length === 0) {
delete hostNode.properties[QUARANTINE_PROPERTY]
return
}
hostNode.properties[QUARANTINE_PROPERTY] = merged
}
export function clearHostQuarantine(hostNode: SubgraphNode): void {
delete hostNode.properties[QUARANTINE_PROPERTY]
}

View File

@@ -0,0 +1,188 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { repairPrimitiveFanout } from '@/core/graph/subgraph/migration/repairPrimitiveFanout'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
interface PrimitiveScenario {
host: SubgraphNode
primitive: LGraphNode
targets: LGraphNode[]
}
function buildPrimitiveScenario(targetCount: number): PrimitiveScenario {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', 'INT')
primitive.addWidget('number', 'value', 42, () => {})
subgraph.add(primitive)
const targets: LGraphNode[] = []
for (let i = 0; i < targetCount; i++) {
const target = new LGraphNode(`Target${i}`)
const slot = target.addInput('value', 'INT')
slot.widget = { name: 'value' }
target.addWidget('number', 'value', 0, () => {})
subgraph.add(target)
primitive.connect(0, target, 0)
targets.push(target)
}
return { host, primitive, targets }
}
function buildCohort(
primitive: LGraphNode,
targets: readonly LGraphNode[],
options: {
hostValuePerEntry?: readonly PendingMigrationEntry['hostValue'][]
} = {}
): PendingMigrationEntry[] {
return targets.map((target, index) => ({
normalized: {
sourceNodeId: String(primitive.id),
sourceWidgetName: 'value',
// Distinguish entries by the downstream target so coalesce keeps each.
disambiguatingSourceNodeId: String(target.id)
},
legacyOrderIndex: index,
hostValue: Object.prototype.hasOwnProperty.call(
options.hostValuePerEntry ?? [],
index
)
? options.hostValuePerEntry![index]
: HOST_VALUE_HOLE,
plan: {
kind: 'primitiveBypass',
primitiveNodeId: primitive.id,
sourceWidgetName: 'value',
targets: targets.map((t) => ({
targetNodeId: t.id,
targetSlot: 0
}))
}
}))
}
describe(repairPrimitiveFanout, () => {
it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => {
const { host, primitive, targets } = buildPrimitiveScenario(3)
const cohort = buildCohort(primitive, targets)
const subgraphInputCountBefore = host.subgraph.inputs.length
const result = repairPrimitiveFanout({ hostNode: host, cohort })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.reconnectCount).toBe(3)
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1)
// After mutation each target's slot should no longer be linked to the primitive.
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('host value (first by legacyOrderIndex) wins over primitive widget value', () => {
const { host, primitive, targets } = buildPrimitiveScenario(2)
const primitiveWidget = primitive.widgets!.find((w) => w.name === 'value')!
primitiveWidget.value = 11
const cohort = buildCohort(primitive, targets, {
hostValuePerEntry: [123, 456]
})
const result = repairPrimitiveFanout({ hostNode: host, cohort })
expect(result.ok).toBe(true)
if (!result.ok) return
const created = host.subgraph.inputs.find(
(i) => i.name === result.subgraphInputName
)
expect(created?._widget?.value).toBe(123)
})
it('preserves an explicit undefined host value instead of falling back to primitive value', () => {
const { host, primitive, targets } = buildPrimitiveScenario(2)
const primitiveWidget = primitive.widgets!.find((w) => w.name === 'value')!
primitiveWidget.value = 11
const cohort = buildCohort(primitive, targets, {
hostValuePerEntry: [undefined, 456]
})
const result = repairPrimitiveFanout({ hostNode: host, cohort })
expect(result.ok).toBe(true)
if (!result.ok) return
const created = host.subgraph.inputs.find(
(i) => i.name === result.subgraphInputName
)
expect(created?._widget?.value).toBeUndefined()
})
it('coalesces duplicate entries that share normalized source', () => {
const { host, primitive, targets } = buildPrimitiveScenario(2)
const cohort = buildCohort(primitive, targets)
// Append an exact duplicate of the first cohort entry.
cohort.push({ ...cohort[0], legacyOrderIndex: 99 })
const result = repairPrimitiveFanout({ hostNode: host, cohort })
expect(result.ok).toBe(true)
if (!result.ok) return
// 2 unique targets → 2 reconnects regardless of duplicate cohort entries.
expect(result.reconnectCount).toBe(2)
})
it('returns primitiveBypassFailed when a target slot type is incompatible', () => {
const { host, primitive, targets } = buildPrimitiveScenario(1)
// Replace the existing target slot type with something incompatible.
targets[0].inputs[0].type = 'STRING'
const cohort = buildCohort(primitive, targets)
const subgraphInputCountBefore = host.subgraph.inputs.length
const result = repairPrimitiveFanout({ hostNode: host, cohort })
expect(result).toEqual({ ok: false, reason: 'primitiveBypassFailed' })
// No new SubgraphInput created.
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore)
})
it('returns primitiveBypassFailed for an empty cohort', () => {
const { host } = buildPrimitiveScenario(0)
const result = repairPrimitiveFanout({ hostNode: host, cohort: [] })
expect(result).toEqual({ ok: false, reason: 'primitiveBypassFailed' })
})
})

View File

@@ -0,0 +1,299 @@
import { isEqual } from 'es-toolkit/compat'
import type {
PendingMigrationEntry,
HostValue,
PrimitiveBypassTargetRef
} from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
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'
type RepairPrimitiveFanoutResult =
| { ok: true; subgraphInputName: string; reconnectCount: number }
| { ok: false; reason: 'primitiveBypassFailed' }
interface RepairPrimitiveFanoutArgs {
hostNode: SubgraphNode
/** All cohort entries whose plan is `primitiveBypass` for this primitive. */
cohort: readonly PendingMigrationEntry[]
}
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
const FAILED: RepairPrimitiveFanoutResult = {
ok: false,
reason: 'primitiveBypassFailed'
}
interface SnapshotLink {
primitiveSlot: number
targetNodeId: NodeId
targetSlot: number
}
function fail(message: string, context?: unknown): RepairPrimitiveFanoutResult {
console.warn(`[repairPrimitiveFanout] ${message}`, context)
return FAILED
}
interface CohortValidationOk {
ok: true
primitiveNodeId: NodeId
sourceWidgetName: string
uniqueEntries: readonly PendingMigrationEntry[]
}
function validateCohort(
cohort: readonly PendingMigrationEntry[]
): CohortValidationOk | { ok: false } {
if (cohort.length === 0) return { ok: false }
const first = cohort[0]
if (first.plan.kind !== 'primitiveBypass') return { ok: false }
const primitiveNodeId = first.plan.primitiveNodeId
const sourceWidgetName = first.plan.sourceWidgetName
for (const entry of cohort) {
if (entry.plan.kind !== 'primitiveBypass') return { ok: false }
if (entry.plan.primitiveNodeId !== primitiveNodeId) return { ok: false }
if (entry.plan.sourceWidgetName !== sourceWidgetName) return { ok: false }
}
// Coalesce exact duplicates by `normalized`.
const uniqueEntries: PendingMigrationEntry[] = []
for (const entry of cohort) {
if (
!uniqueEntries.some((kept) => isEqual(kept.normalized, entry.normalized))
) {
uniqueEntries.push(entry)
}
}
return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries }
}
function pickBaseName(
primitiveNode: LGraphNode,
sourceWidgetName: string
): string {
// Heuristic: a user-renamed PrimitiveNode title differs from its default
// 'PrimitiveNode' label. When unrenamed, fall back to the source widget name.
if (primitiveNode.title && primitiveNode.title !== PRIMITIVE_NODE_TYPE) {
return primitiveNode.title
}
return sourceWidgetName
}
function collectTargets(
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 snapshotLinksForRollback(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): SnapshotLink[] {
const subgraph = hostNode.subgraph
const output = primitiveNode.outputs?.[0]
const linkIds = output?.links ?? []
const snapshot: SnapshotLink[] = []
for (const linkId of linkIds) {
const link = subgraph.links.get(linkId)
if (!link) continue
snapshot.push({
primitiveSlot: link.origin_slot,
targetNodeId: link.target_id,
targetSlot: link.target_slot
})
}
return snapshot
}
function rollback(
hostNode: SubgraphNode,
primitiveNode: LGraphNode,
newSubgraphInput: SubgraphInput | undefined,
snapshot: readonly SnapshotLink[]
): void {
if (newSubgraphInput) {
try {
hostNode.subgraph.removeInput(newSubgraphInput)
} catch (e) {
console.warn('[repairPrimitiveFanout] 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 pickHostValue(
uniqueEntries: readonly PendingMigrationEntry[]
): HostValue {
const ordered = [...uniqueEntries].sort(
(a, b) => a.legacyOrderIndex - b.legacyOrderIndex
)
for (const entry of ordered) {
if (entry.hostValue !== HOST_VALUE_HOLE) return entry.hostValue
}
return HOST_VALUE_HOLE
}
function applyHostValue(widget: IBaseWidget, hostValue: HostValue): void {
if (hostValue === HOST_VALUE_HOLE) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(hostValue)
return
}
widget.value = hostValue as TWidgetValue
}
/**
* All-or-quarantine repair of one primitive's fan-out into a single
* SubgraphInput.
*
* Each call repairs ONE primitive node and the cohort of legacy entries that
* pointed at it. On any failure during validation or mutation, the helper
* rolls back any partial changes and returns
* `{ ok: false, reason: 'primitiveBypassFailed' }` so the caller can
* quarantine all cohort entries.
*/
export function repairPrimitiveFanout(
args: RepairPrimitiveFanoutArgs
): RepairPrimitiveFanoutResult {
const { hostNode, cohort } = args
const validated = validateCohort(cohort)
if (!validated.ok) return fail('cohort validation failed', { cohort })
const subgraph = hostNode.subgraph
const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId)
if (!primitiveNode) {
return fail('primitive node missing', {
primitiveNodeId: validated.primitiveNodeId
})
}
if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) {
return fail('node is not a PrimitiveNode', {
primitiveNodeId: validated.primitiveNodeId,
type: primitiveNode.type
})
}
const targets = collectTargets(hostNode, primitiveNode)
if (!targets || targets.length === 0) {
return fail('no targets to reconnect', {
primitiveNodeId: validated.primitiveNodeId
})
}
const primitiveOutput = primitiveNode.outputs?.[0]
if (!primitiveOutput) return fail('primitive has no output')
const primitiveOutputType = String(primitiveOutput.type ?? '*')
// Pre-validate compatibility of every target before mutating.
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
if (!targetNode) return fail('target node missing', target)
const targetSlot = targetNode.inputs?.[target.targetSlot]
if (!targetSlot) return fail('target slot missing', target)
const targetType = String(targetSlot.type ?? '*')
if (
targetType !== primitiveOutputType &&
targetType !== '*' &&
primitiveOutputType !== '*'
) {
return fail('target slot type incompatible', {
target,
targetType,
primitiveOutputType
})
}
}
const baseName = pickBaseName(primitiveNode, validated.sourceWidgetName)
const existingNames = subgraph.inputs.map((input) => input.name)
const uniqueName = nextUniqueName(baseName, existingNames)
const snapshot = snapshotLinksForRollback(hostNode, primitiveNode)
let newSubgraphInput: SubgraphInput | undefined
try {
newSubgraphInput = subgraph.addInput(uniqueName, primitiveOutputType)
// Disconnect every former primitive→target link.
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)
}
// Reconnect each target slot from the new SubgraphInput, in target order.
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 fail('mutation failed; rolled back', { error: e })
}
// Apply value: prefer first-by-legacyOrderIndex non-hole host value;
// otherwise seed from the primitive's source widget value if present.
const hostValue = pickHostValue(validated.uniqueEntries)
if (hostValue !== HOST_VALUE_HOLE) {
if (newSubgraphInput._widget)
applyHostValue(newSubgraphInput._widget, hostValue)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined && newSubgraphInput._widget) {
newSubgraphInput._widget.value = primitiveValue
}
}
return {
ok: true,
subgraphInputName: newSubgraphInput.name,
reconnectCount: targets.length
}
}

View File

@@ -0,0 +1,330 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { repairValueWidget } from '@/core/graph/subgraph/migration/repairValueWidget'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const graph = hostNode.graph!
graph.add(hostNode)
return hostNode
}
function buildEntry(args: {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
plan: PendingMigrationEntry['plan']
hostValue?: PendingMigrationEntry['hostValue']
}): PendingMigrationEntry {
return {
normalized: {
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
...(args.disambiguatingSourceNodeId && {
disambiguatingSourceNodeId: args.disambiguatingSourceNodeId
})
},
legacyOrderIndex: 0,
hostValue: args.hostValue ?? HOST_VALUE_HOLE,
plan: args.plan
}
}
describe(repairValueWidget, () => {
describe('alreadyLinked plan', () => {
it('hydrates real promoted widget host state without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const innerNode = new LGraphNode('Inner')
const slot = innerNode.addInput('seed', 'INT')
const innerWidget = innerNode.addWidget('number', 'seed', 0, () => {})
slot.widget = { name: innerWidget.name }
subgraph.add(innerNode)
subgraph.inputNode.slots[0].connect(slot, innerNode)
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed' },
hostValue: 99
})
})
expect(result).toEqual({ ok: true, subgraphInputName: 'seed' })
expect(host.widgets[0].value).toBe(99)
expect(innerWidget.value).toBe(0)
})
it('applies host value to the linked input widget (host wins over interior)', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const inputSlot = host.addInput('seed_link', '*')
inputSlot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
value: 7
})
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' },
hostValue: 99
})
})
expect(result).toEqual({ ok: true, subgraphInputName: 'seed_link' })
expect(inputSlot._widget?.value).toBe(99)
})
it('leaves widget value unchanged when hostValue is HOST_VALUE_HOLE', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const inputSlot = host.addInput('seed_link', '*')
inputSlot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
value: 7
})
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' }
})
})
expect(result).toEqual({ ok: true, subgraphInputName: 'seed_link' })
expect(inputSlot._widget?.value).toBe(7)
})
it('routes by subgraphInputName, ignoring legacy disambiguator metadata', () => {
// ADR 0009: canonical PromotedWidgetView no longer carries a
// `disambiguatingSourceNodeId`. Repair routes the host value to the
// input named by `subgraphInputName`; any disambiguator carried on the
// legacy entry is metadata only and does not affect the canonical
// match.
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const firstInput = host.addInput('first_seed', '*')
firstInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
value: 1
})
const secondInput = host.addInput('second_seed', '*')
secondInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
value: 2
})
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: 'second',
plan: { kind: 'alreadyLinked', subgraphInputName: 'second_seed' },
hostValue: 99
})
})
expect(result).toEqual({ ok: true, subgraphInputName: 'second_seed' })
expect(firstInput._widget?.value).toBe(1)
expect(secondInput._widget?.value).toBe(99)
})
it('does not apply host value when already-linked inputs are ambiguous', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const firstInput = host.addInput('first_seed', '*')
firstInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
value: 1
})
const secondInput = host.addInput('second_seed', '*')
secondInput._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
value: 2
})
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
plan: {
kind: 'alreadyLinked',
subgraphInputName: undefined as never
},
hostValue: 99
})
})
expect(result).toEqual({ ok: false, reason: 'ambiguousSubgraphInput' })
expect(firstInput._widget?.value).toBe(1)
expect(secondInput._widget?.value).toBe(2)
})
it('returns missingSubgraphInput when the linked SubgraphInput is gone', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
plan: { kind: 'alreadyLinked', subgraphInputName: 'seed_link' }
})
})
expect(result).toEqual({ ok: false, reason: 'missingSubgraphInput' })
})
})
describe('createSubgraphInput plan', () => {
it('creates exactly one new SubgraphInput linked to the source widget', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
const slot = innerNode.addInput('seed', 'INT')
slot.widget = { name: 'seed' }
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const inputCountBefore = host.subgraph.inputs.length
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
})
})
expect(result.ok).toBe(true)
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
if (result.ok) {
expect(result.subgraphInputName).toBe(created?.name)
}
})
it('returns missingSourceNode when the source node is absent', () => {
const host = buildHost()
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: '999',
sourceWidgetName: 'seed',
plan: { kind: 'createSubgraphInput', sourceWidgetName: 'seed' }
})
})
expect(result).toEqual({ ok: false, reason: 'missingSourceNode' })
})
it('returns missingSourceWidget when the widget is absent on the source node', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
host.subgraph.add(innerNode)
const result = repairValueWidget({
hostNode: host,
entry: buildEntry({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'nonexistent',
plan: {
kind: 'createSubgraphInput',
sourceWidgetName: 'nonexistent'
}
})
})
expect(result).toEqual({ ok: false, reason: 'missingSourceWidget' })
})
})
describe('invalid plan kind', () => {
it('throws on unsupported plan kinds', () => {
const host = buildHost()
const entry = buildEntry({
sourceNodeId: '7',
sourceWidgetName: 'seed',
plan: { kind: 'quarantine', reason: 'missingSourceNode' }
})
expect(() => repairValueWidget({ hostNode: host, entry })).toThrow(
/invalid plan kind/
)
})
})
})

View File

@@ -0,0 +1,173 @@
import type { PendingMigrationEntry } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { HOST_VALUE_HOLE } from '@/core/graph/subgraph/migration/proxyWidgetMigrationPlanTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ProxyWidgetQuarantineReason } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
type RepairValueWidgetResult =
| { ok: true; subgraphInputName: string }
| { ok: false; reason: ProxyWidgetQuarantineReason }
interface RepairValueWidgetArgs {
hostNode: SubgraphNode
entry: PendingMigrationEntry
}
function findHostInputForLinkedSource(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
subgraphInputName: string | undefined
):
| { kind: 'none' }
| { kind: 'one'; input: INodeInputSlot }
| { kind: 'ambiguous' } {
const candidates = subgraphInputName
? hostNode.inputs.filter((input) => input.name === subgraphInputName)
: hostNode.inputs
const matches = candidates.filter((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
return (
widget.sourceNodeId === sourceNodeId &&
widget.sourceWidgetName === sourceWidgetName
)
})
if (matches.length === 0) return { kind: 'none' }
if (matches.length === 1) return { kind: 'one', input: matches[0] }
return { kind: 'ambiguous' }
}
function applyHostValue(
widget: IBaseWidget,
hostValue: PendingMigrationEntry['hostValue']
): void {
if (hostValue === HOST_VALUE_HOLE) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(hostValue)
return
}
widget.value = hostValue as TWidgetValue
}
function repairAlreadyLinked(
hostNode: SubgraphNode,
entry: PendingMigrationEntry
): RepairValueWidgetResult {
const hostInput = findHostInputForLinkedSource(
hostNode,
entry.normalized.sourceNodeId,
entry.normalized.sourceWidgetName,
entry.plan.kind === 'alreadyLinked'
? entry.plan.subgraphInputName
: undefined
)
if (hostInput.kind === 'ambiguous') {
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
if (hostInput.kind === 'none' || !hostInput.input._widget) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput.input._widget, entry.hostValue)
return { ok: true, subgraphInputName: hostInput.input.name }
}
function repairCreateSubgraphInput(
hostNode: SubgraphNode,
entry: PendingMigrationEntry,
sourceWidgetName: string
): RepairValueWidgetResult {
const subgraph = hostNode.subgraph
const sourceNode: LGraphNode | null = subgraph.getNodeById(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
return { ok: false, reason: 'missingSourceNode' }
}
const sourceWidget = sourceNode.widgets?.find(
(w) => w.name === sourceWidgetName
)
if (!sourceWidget) {
return { ok: false, reason: 'missingSourceWidget' }
}
const slot: INodeInputSlot | undefined =
sourceNode.getSlotFromWidget(sourceWidget)
if (!slot) {
// TODO(adr-0009): When the source widget has no backing input slot,
// promotion currently has no canonical path to wire it through a
// SubgraphInput without first synthesizing the slot. The wiring slice
// (slice 5) will reconcile this — for now we surface a quarantine reason
// so the entry is preserved and visible to the user.
console.warn(
'[repairValueWidget] source widget has no backing input slot; quarantining',
{
sourceNodeId: entry.normalized.sourceNodeId,
sourceWidgetName
}
)
return { ok: false, reason: 'missingSubgraphInput' }
}
const existingNames = subgraph.inputs.map((input) => input.name)
const desiredName = nextUniqueName(sourceWidgetName, existingNames)
const slotType = String(slot.type ?? sourceWidget.type ?? '*')
const newSubgraphInput = subgraph.addInput(desiredName, slotType)
// Mirror LGraphNode.configure: input.label → widget.label propagation.
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.hostValue)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
/**
* Repair a single legacy proxy entry into its canonical linked SubgraphInput.
*
* Two valid plan kinds: `'alreadyLinked'` and `'createSubgraphInput'`. Any
* other plan kind is a programmer error (caller bug) and throws. Failures
* during repair return a quarantine reason; the caller is expected to
* append the entry to the host's quarantine via `appendHostQuarantine`.
*/
export function repairValueWidget(
args: RepairValueWidgetArgs
): RepairValueWidgetResult {
const { hostNode, entry } = args
const { plan } = entry
if (plan.kind === 'alreadyLinked') {
return repairAlreadyLinked(hostNode, entry)
}
if (plan.kind === 'createSubgraphInput') {
return repairCreateSubgraphInput(hostNode, entry, plan.sourceWidgetName)
}
throw new Error(`repairValueWidget: invalid plan kind ${plan.kind}`)
}

View File

@@ -0,0 +1,18 @@
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
import { setSubgraphMigrationFlushHook } from '@/lib/litegraph/src/subgraph/subgraphMigrationHook'
/**
* Register the proxyWidget migration flush as the late-bound hook that
* `LGraph.configure()` calls for every host SubgraphNode it materializes.
*
* Called once during app initialization. Safe to call multiple times — the
* registry holds a single function reference.
*/
export function wireProxyWidgetMigrationFlush(): void {
setSubgraphMigrationFlushHook(({ hostNode, nodeData }) => {
flushProxyWidgetMigration({
hostNode,
hostWidgetValues: nodeData?.widgets_values
})
})
}

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,136 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type {
ResolvedPreviewChain,
ResolvedPreviewChainStep
} from './previewExposureTypes'
/**
* Lookup callbacks the chain walker needs to follow nested-host boundaries.
*
* The walker is graph-agnostic: it does not import LGraph. The store layer or
* test harness wires up these callbacks against a real graph or a fixture.
*/
export interface PreviewExposureChainContext {
/**
* Return preview exposures registered for a host execution path.
*/
getExposures(
rootGraphId: UUID,
hostNodeLocator: string
): readonly PreviewExposure[]
/**
* Resolve a source node to its nested host execution path when it is a
* SubgraphNode.
*/
resolveNestedHost(
rootGraphId: UUID,
hostNodeLocator: string,
sourceNodeId: string
): { rootGraphId: UUID; hostNodeLocator: string } | undefined
}
const MAX_CHAIN_DEPTH = 32
function visitedKey(
rootGraphId: UUID,
hostNodeLocator: string,
name: string
): string {
return `${rootGraphId}|${hostNodeLocator}|${name}`
}
/**
* Walk a preview-exposure chain from an outer host through any nested-host
* boundaries down to a leaf source.
*
* @returns The {@link ResolvedPreviewChain} or `undefined` when the named
* exposure does not exist on the starting host.
*
* @remarks
* Cycles are detected via a visited set; a cycle terminates the walk at the
* cycle entry and emits a `console.warn`. The walk also terminates at a fixed
* `MAX_CHAIN_DEPTH` to defend against pathological inputs.
*/
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
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
const key = visitedKey(currentRootGraphId, currentHost, currentName)
if (visited.has(key)) {
console.warn(
`[previewExposureChain] cycle detected at ${key}; terminating walk`
)
break
}
visited.add(key)
const exposures = ctx.getExposures(currentRootGraphId, currentHost)
const exposure = exposures.find((e) => e.name === currentName)
if (!exposure) {
if (steps.length === 0) return undefined
// Source on outer host pointed at a non-existent inner exposure; treat
// the outer step as the leaf and stop walking.
const last = steps[steps.length - 1].exposure
return {
steps,
leaf: {
rootGraphId: currentRootGraphId,
sourceNodeId: last.sourceNodeId,
sourcePreviewName: last.sourcePreviewName
}
}
}
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`
)
if (steps.length === 0) return undefined
const last = steps[steps.length - 1].exposure
return {
steps,
leaf: {
rootGraphId: currentRootGraphId,
sourceNodeId: last.sourceNodeId,
sourcePreviewName: last.sourcePreviewName
}
}
}

View File

@@ -0,0 +1,31 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
/**
* One step along a chain of preview exposures rooted at an outer host.
*/
export interface ResolvedPreviewChainStep {
rootGraphId: UUID
hostNodeLocator: string
exposure: PreviewExposure
}
/**
* The result of walking a preview-exposure chain through zero or more nested
* subgraph hosts.
*
* @remarks
* `steps` is ordered outer-most first. A single-link chain has exactly one
* step. `leaf` describes the final non-host source — the interior node id and
* preview name reached at the bottom of the walk.
*/
export interface ResolvedPreviewChain {
steps: readonly ResolvedPreviewChainStep[]
leaf: {
rootGraphId: UUID
sourceNodeId: string
sourcePreviewName: string
}
}
export type { PreviewExposure }

View File

@@ -10,20 +10,33 @@ export interface ResolvedPromotedWidget {
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
/**
* Legacy proxyWidget tuple shape carried through migration. The optional
* `disambiguatingSourceNodeId` is read from legacy `properties.proxyWidgets`
* payloads only — canonical runtime state never sets it. See ADR 0009.
*/
export interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
/**
* Identity of the immediate interior child whose widget (or input slot, for
* nested SubgraphNode children) this view exposes. Per ADR 0009 each
* SubgraphNode is opaque: the parent's promoted view references the
* immediate child only and does not flatten to deeper origins.
*/
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.
* Per-instance value hydration that writes only to host widget state, never
* cascading into the shared interior widget. Used during configure/clone.
*/
readonly disambiguatingSourceNodeId?: string
hydrateHostValue(value: IBaseWidget['value']): void
}
export function isPromotedWidgetView(

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ 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 { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
@@ -27,6 +28,12 @@ import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidget
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
export function getPromotedWidgetHostStateName(
widget: IPromotedWidgetView
): string {
return [widget.name, widget.sourceNodeId, widget.sourceWidgetName].join(':')
}
interface SubgraphSlotRef {
name: string
label?: string
@@ -41,6 +48,14 @@ function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
return value !== null && typeof value === 'object'
}
function isValueControlWidget(widget: IBaseWidget): boolean {
return (
(widget as Record<symbol, unknown>)[IS_CONTROL_WIDGET] === true &&
typeof widget.beforeQueued === 'function' &&
typeof widget.afterQueued === 'function'
)
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
@@ -56,7 +71,6 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
@@ -64,7 +78,6 @@ export function createPromotedWidgetView(
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
)
}
@@ -100,7 +113,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
@@ -150,12 +162,17 @@ 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']) {
this.setHostWidgetState(value)
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
@@ -200,6 +217,43 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
}
private getHostWidgetState(): WidgetState | undefined {
return useWidgetValueStore().getWidget(
this.graphId,
this.subgraphNode.id,
this.hostWidgetStateName
)
}
private setHostWidgetState(value: IBaseWidget['value']): void {
if (!isWidgetValue(value)) return
const state = this.getHostWidgetState()
if (state) {
state.value = value
return
}
const resolved = this.resolveDeepest()
useWidgetValueStore().registerWidget(this.graphId, {
nodeId: this.subgraphNode.id,
name: this.hostWidgetStateName,
type: resolved?.widget.type ?? 'button',
value,
// Clone — never share the interior widget's options reference, or
// host-state mutations (e.g. disabled toggle) leak into the shared
// interior across every SubgraphNode instance.
options: { ...(resolved?.widget.options ?? {}) },
label: this.displayName,
serialize: this.serialize,
disabled: this.computedDisabled
})
}
private get hostWidgetStateName(): string {
return getPromotedWidgetHostStateName(this)
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
@@ -217,6 +271,16 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (state) state.label = value
}
/**
* Write a value into this host's widget store entry without cascading into
* the shared interior widget — the only safe path for per-instance hydration
* during `configure()` and clone, where multiple SubgraphNode instances
* reference the same shared interior nodes.
*/
hydrateHostValue(value: IBaseWidget['value']): void {
this.setHostWidgetState(value)
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
@@ -351,14 +415,50 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
beforeQueued(): void {
// Source widgets linked through subgraph inputs are inert for prompt
// serialization. Control-after-generate is applied to the promoted host
// value in afterQueued so the next prompt uses the updated SubgraphNode
// value, not the linked source value.
}
afterQueued(): void {
this.applyValueControlToHost()
}
private applyValueControlToHost(): void {
const resolved = this.resolveAtHost()
const controlWidget =
resolved?.widget.linkedWidgets?.find(isValueControlWidget)
if (!controlWidget) return
const mode = controlWidget.value
if (mode === 'fixed') return
const current = this.value
if (typeof current !== 'number') return
const { min = 0, max = 1, step2 = 1 } = this.options
let next = current
if (mode === 'increment') next += step2
else if (mode === 'decrement') next -= step2
else if (mode === 'randomize') {
const safeMax = Math.min(1125899906842624, max)
const safeMin = Math.max(-1125899906842624, min)
const range = (safeMax - safeMin) / step2
next = Math.floor(Math.random() * range) * step2 + safeMin
}
next = Math.min(Math.max(next, min), max)
this.value = next
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName,
this.disambiguatingSourceNodeId
this.sourceWidgetName
)
}
@@ -372,8 +472,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 +512,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
)
}

View File

@@ -9,7 +9,12 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
@@ -19,11 +24,16 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
getSourceNodeId,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected
pruneDisconnected,
reorderSubgraphInputAtIndex,
reorderSubgraphInputsByName,
reorderSubgraphInputsByWidgetOrder
} from './promotionUtils'
function widget(
@@ -112,58 +122,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 +248,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 +312,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,17 +321,21 @@ 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', () => {
it('hydrates $$canvas-image-preview exposure 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).
@@ -284,13 +348,17 @@ describe('promoteRecommendedWidgets', () => {
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
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
})
})
})
@@ -314,12 +382,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 +483,225 @@ 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)
})
})
describe('reorderSubgraphInputAtIndex', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('moves host widget values with dragged input rows', () => {
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'
reorderSubgraphInputAtIndex(host, 0, 1)
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(host.widgets.map((widget) => widget.value)).toEqual([
'second value',
'first value'
])
})
it('updates subgraph link slot indices after moving a row', () => {
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)
reorderSubgraphInputAtIndex(host, 0, 1)
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)
})
})
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) => getSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
})

View File

@@ -1,6 +1,7 @@
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getPromotedWidgetHostStateName } from '@/core/graph/subgraph/promotedWidgetView'
import { t } from '@/i18n'
import type {
IContextMenuValue,
@@ -8,6 +9,7 @@ import type {
} 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 { nextUniqueName } from '@/lib/litegraph/src/strings'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
@@ -15,14 +17,23 @@ 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 { useWidgetValueStore } from '@/stores/widgetValueStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export type WidgetItem = [LGraphNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
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'
}
export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
@@ -37,7 +48,20 @@ export function isLinkedPromotion(
sourceNodeId: string,
sourceWidgetName: string
): boolean {
return subgraphNode.inputs.some((input) => {
return (
findHostInputForPromotion(subgraphNode, sourceNodeId, sourceWidgetName) !==
undefined
)
}
/** Find the host input on `subgraphNode` whose `_widget` is the
* `PromotedWidgetView` for `(sourceNodeId, sourceWidgetName)`. */
function findHostInputForPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
const w = input._widget
return (
w &&
@@ -48,9 +72,175 @@ export function isLinkedPromotion(
})
}
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)
}
export function reorderSubgraphInputAtIndex(
subgraphNode: SubgraphNode,
oldPosition: number,
newPosition: number
): void {
if (
oldPosition < 0 ||
newPosition < 0 ||
oldPosition >= subgraphNode.subgraph.inputs.length ||
newPosition >= subgraphNode.subgraph.inputs.length
)
return
const orderedIndices = subgraphNode.subgraph.inputs.map((_, index) => index)
const [movedIndex] = orderedIndices.splice(oldPosition, 1)
if (movedIndex !== undefined)
orderedIndices.splice(newPosition, 0, movedIndex)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const rows = subgraphNode.subgraph.inputs.map((input, index) => ({
subgraphInput: input,
hostInput: subgraphNode.inputs[index],
value: getExplicitHostWidgetValue(
subgraphNode,
subgraphNode.inputs[index]?._widget
)
}))
const orderedRows = orderedIndices.flatMap((index) => rows[index] ?? [])
subgraphNode.subgraph.inputs.splice(
0,
subgraphNode.subgraph.inputs.length,
...orderedRows.map((row) => row.subgraphInput)
)
subgraphNode.inputs.splice(
0,
subgraphNode.inputs.length,
...orderedRows.flatMap((row) => row.hostInput ?? [])
)
for (const [index, input] of subgraphNode.subgraph.inputs.entries()) {
for (const linkId of input.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (link) link.origin_slot = index
}
}
for (const row of orderedRows) {
const widget = row.hostInput?._widget
if (widget && row.value !== undefined) widget.value = row.value
}
}
function getExplicitHostWidgetValue(
subgraphNode: SubgraphNode,
widget: IBaseWidget | undefined
): IBaseWidget['value'] | undefined {
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
const state = useWidgetValueStore().getWidget(
subgraphNode.rootGraph.id,
subgraphNode.id,
getPromotedWidgetHostStateName(widget)
)
return state && isWidgetValue(state.value) ? state.value : undefined
}
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
return w.sourceNodeId
}
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
)
}
function isPromotedOnParent(
subgraphNode: SubgraphNode,
widget: IBaseWidget,
source: PromotedWidgetSource
): boolean {
if (isPreviewPseudoWidget(widget))
return isPreviewExposed(subgraphNode, source)
return isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
)
}
export function isWidgetPromotedOnSubgraphNode(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
return (
isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
) || isPreviewExposed(subgraphNode, source)
)
}
function toPromotionSource(
@@ -59,21 +249,75 @@ function toPromotionSource(
): PromotedWidgetSource {
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: getWidgetName(widget)
}
}
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
for (const parent of parents) {
parent.computeSize(parent.size)
parent.setDirtyCanvas(true, true)
parent.setDirtyCanvas?.(true, true)
}
useCanvasStore().canvas?.setDirty(true, true)
}
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
})
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
@@ -98,10 +342,19 @@ export function promoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, source)
if (isPreviewPseudoWidget(widget)) {
promotePreviewViaExposure(
parent,
node as LGraphNode,
source.sourceWidgetName
)
continue
}
if ('getSlotFromWidget' in node) {
promoteValueWidgetViaSubgraphInput(parent, node as LGraphNode, widget)
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -116,10 +369,40 @@ 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) {
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 +435,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) => !isPromotedOnParent(parent, widget, source)
)
if (promotableParents.length > 0)
options.unshift({
@@ -189,10 +471,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) => !isPromotedOnParent(parent, widget, source)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -248,7 +529,6 @@ function nodeWidgets(n: LGraphNode): WidgetItem[] {
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
@@ -260,14 +540,7 @@ 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).
@@ -282,19 +555,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// 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
}
@@ -305,43 +566,42 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
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)
)
promoteValueWidgetViaSubgraphInput(subgraphNode, n, w)
}
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
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,24 +610,22 @@ 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, {
!isPromotedOnParent(subgraphNode, widget, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})

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,30 +20,21 @@ 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) {
@@ -52,8 +43,7 @@ function traversePromotedWidgetChain(
const sourceWidget = findWidgetByIdentity(
sourceNode.widgets,
currentWidgetName,
currentSourceNodeId
currentWidgetName
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
@@ -73,7 +63,6 @@ function traversePromotedWidgetChain(
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
currentSourceNodeId = undefined
}
return { status: 'failure', failure: 'max-depth-exceeded' }
@@ -81,34 +70,20 @@ function traversePromotedWidgetChain(
function findWidgetByIdentity(
widgets: IBaseWidget[] | undefined,
widgetName: string,
sourceNodeId?: string
widgetName: 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)
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 = findWidgetByIdentity(node.widgets, widgetName)
if (!widget) return undefined
return { node, widget }
@@ -117,11 +92,10 @@ 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)
}

View File

@@ -14,8 +14,7 @@ export function resolvePromotedWidgetSource(
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved

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(
@@ -17,29 +15,18 @@ export function resolveSubgraphInputTarget(
node,
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
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
}
}
// ADR 0009: each SubgraphNode is opaque. The promoted target is the
// child SubgraphNode's input slot, not a deeper leaf widget.
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name
}
}
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.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,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,35 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
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[] {
if (property === undefined) return []
try {
if (typeof property === 'string') property = JSON.parse(property)
const result = previewExposuresPropertySchema.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.previewExposures:\n${error}`
)
} catch (e) {
console.warn('Failed to parse properties.previewExposures:', e)
}
return []
}

View File

@@ -3,11 +3,14 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetTupleSchema = z.union([
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(

View File

@@ -0,0 +1,80 @@
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('parses a valid entry without hostValue', () => {
expect(parseProxyWidgetErrorQuarantine([baseEntry])).toEqual([baseEntry])
})
it('parses a valid entry with hostValue', () => {
const entry = { ...baseEntry, hostValue: 42 }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it('parses a 2-tuple originalEntry', () => {
const entry = { ...baseEntry, originalEntry: ['10', 'seed'] }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it('parses a 3-tuple originalEntry', () => {
const entry = { ...baseEntry, originalEntry: ['3', 'text', '1'] }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it.each([
'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', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('returns empty array for malformed JSON string', () => {
expect(parseProxyWidgetErrorQuarantine('not-json{')).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(parseProxyWidgetErrorQuarantine(baseEntry)).toEqual([])
})
it('returns empty array when attemptedAtVersion is not 1', () => {
const entry = { ...baseEntry, attemptedAtVersion: 2 }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
})
it('returns empty array when reason is not in the enum', () => {
const entry = { ...baseEntry, reason: 'somethingElse' }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
})
it('returns empty array when originalEntry is malformed', () => {
const entry = { ...baseEntry, originalEntry: ['only-one'] }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
})
})

View File

@@ -0,0 +1,56 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
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[] {
if (property === undefined) return []
try {
const result = proxyWidgetErrorQuarantinePropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data as ProxyWidgetErrorQuarantineEntry[]
const error = fromZodError(result.error)
console.warn(
`Invalid assignment for properties.proxyWidgetErrorQuarantine:\n${error}`
)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgetErrorQuarantine:', e)
}
return []
}

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

@@ -13,7 +13,7 @@ import {
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 {
createTestSubgraphData,
@@ -280,17 +280,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 +305,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(
[]
)
})
})

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'
@@ -54,6 +50,7 @@ import type {
Size
} from './interfaces'
import { LiteGraph, SubgraphNode } from './litegraph'
import { runSubgraphMigrationFlushHook } from './subgraph/subgraphMigrationHook'
import {
alignOutsideContainer,
alignToContainer,
@@ -379,7 +376,7 @@ export class LGraph
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
usePromotionStore().clearGraph(graphId)
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}
@@ -1927,13 +1924,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', {
@@ -1946,75 +1936,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 }
@@ -2735,6 +2656,16 @@ export class LGraph
this.updateExecutionOrder()
// ADR 0009: forward-ratchet legacy properties.proxyWidgets on each
// host SubgraphNode. Late-bound hook (registered in app init) so the
// LGraph layer doesn't pull in the PreviewExposureStore at module
// load — that would create a circular dependency.
for (const node of this._nodes) {
if (!(node instanceof SubgraphNode)) continue
if (node.properties?.proxyWidgets === undefined) continue
runSubgraphMigrationFlushHook(node, nodeDataMap.get(node.id))
}
this.onConfigure?.(data)
this.incrementVersion()

View File

@@ -9065,9 +9065,9 @@ 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.
* node IDs in the root graph. Also patches subgraph link node IDs and legacy
* SubgraphNode `properties.proxyWidgets` references so migration input stays
* aligned with remapped interior IDs until the ADR 0009 flush consumes it.
*/
export function remapClipboardSubgraphNodeIds(
parsed: ClipboardItems,

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

@@ -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' }
])
}
)
})

View File

@@ -0,0 +1,897 @@
/**
* Tests for SubgraphNode.serialize() after ADR 0009.
*
* Covers:
* - Removed copy-back loop: exterior promoted host value does NOT mutate
* the corresponding interior widget value.
* - properties.proxyWidgets is no longer re-emitted on serialize.
* - properties.previewExposures round-trip through the
* PreviewExposureStore.
* - properties.proxyWidgetErrorQuarantine round-trips and is inert at
* runtime; an empty quarantine is omitted from the serialized payload.
*/
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
appendHostQuarantine,
makeQuarantineEntry
} from '@/core/graph/subgraph/migration/quarantineEntry'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import type { ISlotType, TWidgetType } from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import {
reorderSubgraphInputAtIndex,
reorderSubgraphInputsByName
} from '@/core/graph/subgraph/promotionUtils'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { graphToPrompt } from '@/utils/executionUtil'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNodeWithWidget(
title: string,
widgetType: TWidgetType = 'number',
widgetValue: unknown = 42,
slotType: ISlotType = 'number'
) {
const node = new LGraphNode(title)
const input = node.addInput('value', slotType)
node.addOutput('out', slotType)
// @ts-expect-error Abstract class instantiation
const widget = new BaseWidget({
name: 'widget',
type: widgetType,
value: widgetValue,
y: 0,
options: widgetType === 'number' ? { min: 0, max: 100, step: 1 } : {},
node
})
node.widgets = [widget]
input.widget = { name: widget.name }
return { node, widget, input }
}
function expectPromotedWidgetView(
widget: unknown
): asserts widget is PromotedWidgetView {
expect(widget).toMatchObject({
sourceNodeId: expect.any(String),
sourceWidgetName: expect.any(String)
})
}
function getHostStateName(widget: PromotedWidgetView): string {
return [widget.name, widget.sourceNodeId, widget.sourceWidgetName].join(':')
}
describe('SubgraphNode.serialize (ADR 0009)', () => {
describe('removed copy-back loop', () => {
it('does not mutate interior widget values during serialize', () => {
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: getHostStateName(hostWidget),
type: hostWidget.type,
value: 99,
options: {}
})
hostNode.serialize()
expect(interiorWidget.value).toBe(42)
})
it('does not mutate live properties while projecting store-owned serialization metadata', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.properties.previewExposures = [
{
name: 'stale',
sourceNodeId: '0',
sourcePreviewName: '$$canvas-image-preview'
}
]
hostNode.properties.proxyWidgetErrorQuarantine = []
const livePropertiesBefore = structuredClone(hostNode.properties)
usePreviewExposureStore().addExposure(
hostNode.rootGraph.id,
String(hostNode.id),
{
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
)
const serialized = hostNode.serialize()
expect(hostNode.properties).toEqual(livePropertiesBefore)
expect(serialized.properties?.previewExposures).toEqual([
{
name: '$$canvas-image-preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
])
expect(serialized.properties?.proxyWidgetErrorQuarantine).toBeUndefined()
})
})
describe('host widget values', () => {
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('keeps promoted values attached to their inputs after reordering', () => {
const subgraph = createTestSubgraph()
const first = createNodeWithWidget('First', 'number', 1)
const second = createNodeWithWidget('Second', 'number', 2)
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'number')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'number')
secondInput.connect(second.input, second.node)
const host = createTestSubgraphNode(subgraph)
host.widgets[0].value = 111
host.widgets[1].value = 222
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.serialize().widgets_values).toEqual([222, 111])
})
it('does not persist source widget store fallback values after reordering', () => {
const subgraph = createTestSubgraph()
const first = createNodeWithWidget('First', 'text', '', 'STRING')
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'STRING')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'STRING')
secondInput.connect(second.input, second.node)
const host = createTestSubgraphNode(subgraph)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: first.node.id,
name: first.widget.name,
type: first.widget.type,
value: 'first value',
options: {}
})
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: second.node.id,
name: second.widget.name,
type: second.widget.type,
value: 'second 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()
const first = createNodeWithWidget('First', 'text', '', 'STRING')
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'STRING')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'STRING')
secondInput.connect(second.input, second.node)
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 firstReloadedWidget = reloaded.widgets[0]
const secondReloadedWidget = reloaded.widgets[1]
expectPromotedWidgetView(firstReloadedWidget)
expectPromotedWidgetView(secondReloadedWidget)
expect(
widgetStore.getWidget(
reloaded.rootGraph.id,
reloaded.id,
getHostStateName(firstReloadedWidget)
)
).toBeUndefined()
expect(
widgetStore.getWidget(
reloaded.rootGraph.id,
reloaded.id,
getHostStateName(secondReloadedWidget)
)?.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'
])
})
it('moves Vue-edited values with promoted widgets after reordering', () => {
const subgraph = createTestSubgraph()
const first = createNodeWithWidget('First', 'text', '', 'STRING')
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'STRING')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'STRING')
secondInput.connect(second.input, second.node)
const host = createTestSubgraphNode(subgraph)
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[0].updateHandler('first value')
widgets[1].updateHandler('second value')
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
it('sends Vue-edited values to dragged promoted widget targets', async () => {
const subgraph = createTestSubgraph()
const first = createNodeWithWidget('First', 'text', '', 'STRING')
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
first.node.comfyClass = 'First'
second.node.comfyClass = 'Second'
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'STRING')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'STRING')
secondInput.connect(second.input, second.node)
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
host.graph?.add(host)
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[0].updateHandler('first value')
widgets[1].updateHandler('second value')
reorderSubgraphInputsByName(host, ['second', 'first'])
const { output } = await graphToPrompt(host.rootGraph)
expect(output[`${host.id}:${first.node.id}`].inputs.value).toBe(
'first value'
)
expect(output[`${host.id}:${second.node.id}`].inputs.value).toBe(
'second value'
)
})
it('keeps text and seed values on their targets when the seed input moves up', async () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
positive.node.comfyClass = 'Positive'
seed.node.comfyClass = 'Sampler'
negative.node.comfyClass = 'Negative'
subgraph.add(positive.node)
subgraph.add(seed.node)
subgraph.add(negative.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
host.graph?.add(host)
host.widgets[0].value = 'positive prompt'
host.widgets[1].value = 'negative prompt'
host.widgets[2].value = 123456
reorderSubgraphInputAtIndex(host, 2, 1)
const { output } = await graphToPrompt(host.rootGraph)
expect(host.serialize().widgets_values).toEqual([
'positive prompt',
123456,
'negative prompt'
])
expect(output[`${host.id}:${positive.node.id}`].inputs.value).toBe(
'positive prompt'
)
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
expect(output[`${host.id}:${negative.node.id}`].inputs.value).toBe(
'negative prompt'
)
})
it('keeps Vue-edited text and seed values on their targets when the seed input moves up', async () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
positive.node.comfyClass = 'Positive'
negative.node.comfyClass = 'Negative'
seed.node.comfyClass = 'Sampler'
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
host.graph?.add(host)
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[0].updateHandler('positive prompt')
widgets[1].updateHandler('negative prompt')
widgets[2].updateHandler(123456)
reorderSubgraphInputAtIndex(host, 2, 1)
const { output } = await graphToPrompt(host.rootGraph)
expect(host.serialize().widgets_values).toEqual([
'positive prompt',
123456,
'negative prompt'
])
expect(output[`${host.id}:${positive.node.id}`].inputs.value).toBe(
'positive prompt'
)
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
expect(output[`${host.id}:${negative.node.id}`].inputs.value).toBe(
'negative prompt'
)
})
it('ignores direct source seed changes after the seed input moves up', async () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
positive.node.comfyClass = 'Positive'
negative.node.comfyClass = 'Negative'
seed.node.comfyClass = 'Sampler'
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
host.graph?.add(host)
host.widgets[0].value = 'positive prompt'
host.widgets[1].value = 'negative prompt'
host.widgets[2].value = 123456
reorderSubgraphInputAtIndex(host, 2, 1)
seed.widget.linkedWidgets = [
{
name: 'control_after_generate',
value: 'increment',
serialize: false,
beforeQueued: () => {},
afterQueued: () => {}
} as never
]
seed.widget.value = 789
const { output } = await graphToPrompt(host.rootGraph)
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
})
it('syncs Vue-edited promoted seed values to the controlled source widget after moving seed up', async () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
seed.widget.options.max = 1125899906842624
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
seed.widget.linkedWidgets = [
{
name: 'control_after_generate',
value: 'fixed',
serialize: false,
beforeQueued: () => {},
afterQueued: () => {}
} as never
]
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[2].updateHandler(123456)
reorderSubgraphInputAtIndex(host, 2, 1)
expect(seed.widget.value).toBe(123456)
})
it('shows a control-updated promoted seed value in processed widgets after moving seed up', () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
seed.widget.options.max = 1125899906842624
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[2].updateHandler(123456)
seed.widget.linkedWidgets = [
{
name: 'control_after_generate',
value: 'increment',
serialize: false,
beforeQueued: () => {},
afterQueued: () => {},
[IS_CONTROL_WIDGET]: true
} as never
]
reorderSubgraphInputAtIndex(host, 2, 1)
host.widgets[1].afterQueued?.()
const updatedNodeData = extractVueNodeData(host)
const updatedWidgets = computeProcessedWidgets({
nodeData: updatedNodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
expect(updatedWidgets[1].value).toBe(123457)
})
it('increments the promoted host seed without using the source seed value', () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
seed.widget.options.max = 1125899906842624
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
seed.widget.linkedWidgets = [
{
name: 'control_after_generate',
value: 'increment',
serialize: false,
beforeQueued: () => {},
afterQueued: () => {},
[IS_CONTROL_WIDGET]: true
} as never
]
host.widgets[2].value = 2
reorderSubgraphInputAtIndex(host, 2, 1)
seed.widget.value = 8
host.widgets[1].afterQueued?.()
expect(host.widgets[1].value).toBe(3)
expect(
useWidgetValueStore()
.getNodeWidgets(host.rootGraph.id, host.id)
.find((entry) => entry.name.startsWith('seed:'))?.value
).toBe(3)
})
})
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)
// Ensure no pre-existing proxyWidgets property leaks through.
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()
// Still serialized as-is — not deleted, not rewritten.
expect(serialized.properties?.proxyWidgets).toStrictEqual(legacy)
})
})
describe('previewExposures round-trip', () => {
it('hydrates previewExposures into the store during configure', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const rootGraphId = hostNode.rootGraph.id
const hostLocator = String(hostNode.id)
hostNode.properties.previewExposures = [
{
name: 'preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
]
hostNode._internalConfigureAfterSlots()
expect(
usePreviewExposureStore().getExposures(rootGraphId, hostLocator)
).toEqual([
{
name: 'preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
])
})
it('writes previewExposures from the store on serialize', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const store = usePreviewExposureStore()
const rootGraphId = hostNode.rootGraph.id
const hostLocator = String(hostNode.id)
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
})
const serialized = hostNode.serialize()
expect(serialized.properties?.previewExposures).toEqual([
{
name: '$$canvas-image-preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
},
{
name: 'videopreview',
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
}
])
})
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()
const rootGraphId = firstHost.rootGraph.id
store.addExposure(rootGraphId, String(firstHost.id), {
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(rootGraphId, String(secondHost.id), {
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
})
const firstExposures = firstHost.serialize().properties?.previewExposures
const secondExposures =
secondHost.serialize().properties?.previewExposures
expect(Array.isArray(firstExposures)).toBe(true)
expect(Array.isArray(secondExposures)).toBe(true)
if (!Array.isArray(firstExposures) || !Array.isArray(secondExposures))
throw new Error('Expected serialized previewExposures arrays')
expect(firstExposures).toEqual([
{
name: '$$canvas-image-preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
])
expect(secondExposures).toEqual([
{
name: 'videopreview',
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
}
])
expect(firstExposures?.[0]).not.toHaveProperty('hostInstanceId')
expect(firstExposures?.[0]).not.toHaveProperty('hostNodeLocator')
expect(firstExposures?.[0]).not.toHaveProperty('rootGraphId')
expect(secondExposures?.[0]).not.toHaveProperty('hostInstanceId')
expect(secondExposures?.[0]).not.toHaveProperty('hostNodeLocator')
expect(secondExposures?.[0]).not.toHaveProperty('rootGraphId')
})
it('omits previewExposures when the store has no entries for the host', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.properties.previewExposures = [
{
name: 'stale',
sourceNodeId: '0',
sourcePreviewName: '$$canvas-image-preview'
}
]
const serialized = hostNode.serialize()
expect(serialized.properties?.previewExposures).toBeUndefined()
expect(hostNode.properties.previewExposures).toEqual([
{
name: 'stale',
sourceNodeId: '0',
sourcePreviewName: '$$canvas-image-preview'
}
])
})
})
describe('proxyWidgetErrorQuarantine', () => {
it('preserves quarantine entries through serialize and is inert at runtime', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
appendHostQuarantine(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)
// Inertness: quarantine entries do not produce widgets.
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([])
})
})
})

View File

@@ -28,27 +28,27 @@ import type {
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import {
createPromotedWidgetView,
getPromotedWidgetHostStateName,
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -70,6 +70,14 @@ type LinkedPromotionEntry = PromotedWidgetSource & {
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
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'
}
/**
* An instance of a {@link Subgraph}, displayed as a node on the containing (parent) graph.
*/
@@ -96,22 +104,16 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _promotedViewManager =
new PromotedWidgetViewManager<PromotedWidgetView>()
/**
* Promotions buffered before this node is attached to a graph (`id === -1`).
* They are flushed in `_flushPendingPromotions()` from `_setWidget()` and
* `onAdded()`, so construction-time promotions require normal add-to-graph
* lifecycle to persist.
*/
private _pendingPromotions: PromotedWidgetSource[] = []
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
inputOrderKey: string
hasMissingBoundSourceWidget: boolean
entries: LinkedPromotionEntry[]
}
private _promotedViewsCache?: {
version: number
entriesRef: PromotedWidgetSource[]
inputOrderKey: string
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
@@ -142,15 +144,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (!targetWidget) continue
if (inputNode.isSubgraphNode()) {
if (isPromotedWidgetView(targetWidget)) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.sourceWidgetName,
disambiguatingSourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
// ADR 0009: each SubgraphNode is opaque. The promoted source on the
// parent host always references the immediate child's input slot, not
// the deeper leaf widget identity that the child internally exposes.
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
@@ -166,10 +162,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
const inputOrderKey = this._getInputOrderKey()
const cached = this._linkedEntriesCache
if (
cache &&
cached?.version === this._cacheVersion &&
cached.inputOrderKey === inputOrderKey &&
cached.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
)
return cached.entries
@@ -220,8 +218,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
entry.inputName
)
if (seenEntryKeys.has(entryKey)) return false
@@ -232,6 +229,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (cache)
this._linkedEntriesCache = {
version: this._cacheVersion,
inputOrderKey,
hasMissingBoundSourceWidget,
entries: deduplicatedEntries
}
@@ -257,21 +255,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _getPromotedViews(): PromotedWidgetView[] {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
const inputOrderKey = this._getInputOrderKey()
const cachedViews = this._promotedViewsCache
if (
cachedViews?.version === this._cacheVersion &&
cachedViews.entriesRef === entries &&
cachedViews.inputOrderKey === inputOrderKey &&
cachedViews.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
)
return cachedViews.views
const linkedEntries = this._getLinkedPromotionEntries()
const { displayNameByViewKey, reconcileEntries } =
this._buildPromotionReconcileState(entries, linkedEntries)
const displayNameByViewKey = this._buildDisplayNameByViewKey(linkedEntries)
const reconcileEntries = this._buildLinkedReconcileEntries(linkedEntries)
const views = this._promotedViewManager.reconcile(
reconcileEntries,
@@ -281,14 +277,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId,
entry.slotName
)
)
this._promotedViewsCache = {
version: this._cacheVersion,
entriesRef: entries,
inputOrderKey,
hasMissingBoundSourceWidget,
views
}
@@ -296,303 +291,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return views
}
private _getInputOrderKey(): string {
return this.inputs
.map((input) => input._subgraphSlot?.id ?? input.name)
.join('|')
}
private _invalidatePromotedViewsCache(): void {
this._cacheVersion++
}
private _syncPromotions(): void {
if (this.id === -1) return
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const linkedEntries = this._getLinkedPromotionEntries(false)
// Intentionally preserve independent store promotions when linked coverage is partial;
// tests assert that mixed linked/independent states must not collapse to linked-only.
const { mergedEntries } = this._buildPromotionPersistenceState(
entries,
linkedEntries
)
const hasChanged =
mergedEntries.length !== entries.length ||
mergedEntries.some(
(entry, index) =>
entry.sourceNodeId !== entries[index]?.sourceNodeId ||
entry.sourceWidgetName !== entries[index]?.sourceWidgetName ||
entry.disambiguatingSourceNodeId !==
entries[index]?.disambiguatingSourceNodeId
)
if (!hasChanged) return
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
}
private _buildPromotionReconcileState(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
reconcileEntries: Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
slotName?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
entries,
linkedEntries
)
const linkedReconcileEntries =
this._buildLinkedReconcileEntries(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
const fallbackReconcileEntries = fallbackStoredEntries.map((e) =>
e.disambiguatingSourceNodeId
? {
sourceNodeId: e.sourceNodeId,
sourceWidgetName: e.sourceWidgetName,
disambiguatingSourceNodeId: e.disambiguatingSourceNodeId,
viewKey: `src:${e.sourceNodeId}:${e.sourceWidgetName}:${e.disambiguatingSourceNodeId}`
}
: e
)
const reconcileEntries = shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackReconcileEntries]
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
reconcileEntries
}
}
private _buildPromotionPersistenceState(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: PromotedWidgetSource[]
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
return {
mergedEntries: shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries]
}
}
private _collectLinkedAndFallbackEntries(
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: PromotedWidgetSource[]
fallbackStoredEntries: PromotedWidgetSource[]
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const excludedEntryKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
for (const key of connectedEntryKeys) {
excludedEntryKeys.add(key)
}
const prePruneFallbackStoredEntries = this._getFallbackStoredEntries(
entries,
excludedEntryKeys
)
const fallbackStoredEntries = this._pruneStaleAliasFallbackEntries(
prePruneFallbackStoredEntries,
linkedPromotionEntries
)
return {
linkedPromotionEntries,
fallbackStoredEntries
}
}
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[],
fallbackStoredEntries: PromotedWidgetSource[]
): boolean {
if (
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
)
return false
const linkedEntryKeys = new Set(
linkedEntries.map((entry) =>
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
)
const linkedWidgetNames = new Set(
linkedEntries.map((entry) => entry.sourceWidgetName)
)
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
const sourceNode = this.subgraph.getNodeById(entry.sourceNodeId)
if (!sourceNode) return linkedWidgetNames.has(entry.sourceWidgetName)
if (sourceNode.type === 'PrimitiveNode') return true
const hasSourceWidget =
sourceNode.widgets?.some(
(widget) => widget.name === entry.sourceWidgetName
) === true
if (hasSourceWidget) return true
// If the fallback entry overlaps a linked entry, keep it
// until aliasing can be positively proven.
return linkedEntryKeys.has(
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
})
return !hasFallbackToKeep
}
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): PromotedWidgetSource[] {
return linkedEntries.map(
({ sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId }) => ({
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
})
)
}
private _getFallbackStoredEntries(
entries: PromotedWidgetSource[],
excludedEntryKeys: Set<string>
): PromotedWidgetSource[] {
return entries.filter(
(entry) =>
!excludedEntryKeys.has(
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
}
private _pruneStaleAliasFallbackEntries(
fallbackStoredEntries: PromotedWidgetSource[],
linkedPromotionEntries: PromotedWidgetSource[]
): PromotedWidgetSource[] {
if (
fallbackStoredEntries.length === 0 ||
linkedPromotionEntries.length === 0
)
return fallbackStoredEntries
const linkedConcreteKeys = new Set(
linkedPromotionEntries
.map((entry) => this._resolveConcretePromotionEntryKey(entry))
.filter((key): key is string => key !== undefined)
)
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
const prunedEntries: PromotedWidgetSource[] = []
for (const entry of fallbackStoredEntries) {
if (!this.subgraph.getNodeById(entry.sourceNodeId)) continue
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
prunedEntries.push(entry)
}
return prunedEntries
}
private _resolveConcretePromotionEntryKey(
entry: PromotedWidgetSource
): string | undefined {
const result = resolveConcretePromotedWidget(
this,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
if (result.status !== 'resolved') return undefined
return this._makePromotionEntryKey(
String(result.resolved.node.id),
result.resolved.widget.name
)
}
private _getConnectedPromotionEntryKeys(): Set<string> {
const connectedEntryKeys = new Set<string>()
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const widget of connectedWidgets) {
if (!hasWidgetNode(widget)) continue
connectedEntryKeys.add(
this._makePromotionEntryKey(String(widget.node.id), widget.name)
)
}
}
return connectedEntryKeys
}
private _buildLinkedReconcileEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey: string
disambiguatingSourceNodeId?: string
slotName: string
}> {
return linkedEntries.map(
({
inputKey,
inputName,
slotName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
({ inputKey, inputName, slotName, sourceNodeId, sourceWidgetName }) => ({
sourceNodeId,
sourceWidgetName,
slotName,
disambiguatingSourceNodeId,
viewKey: this._makePromotionViewKey(
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
inputName
)
})
)
@@ -607,77 +333,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
entry.inputName
),
entry.inputName
])
)
}
private _makePromotionEntryKey(
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): string {
return makePromotionEntryKey({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
})
}
private _makePromotionViewKey(
inputKey: string,
sourceNodeId: string,
sourceWidgetName: string,
inputName = '',
disambiguatingSourceNodeId?: string
inputName = ''
): string {
return disambiguatingSourceNodeId
? JSON.stringify([
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
])
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
private _serializeEntries(
entries: PromotedWidgetSource[]
): (string[] | [string, string, string])[] {
return entries.map((e) =>
e.disambiguatingSourceNodeId
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
: [e.sourceNodeId, e.sourceWidgetName]
)
}
private _resolveLegacyEntry(
widgetName: string
): [string, string] | undefined {
// Legacy -1 entries use the slot name as the widget name.
// Find the input with that name, then trace to the connected interior widget.
const input = this.inputs.find((i) => i.name === widgetName)
if (!input?._widget) {
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
}
const widget = input._widget
if (isPromotedWidgetView(widget)) {
return [widget.sourceNodeId, widget.sourceWidgetName]
}
// Fallback: find via subgraph input slot connection
const resolvedTarget = resolveSubgraphInputTarget(this, widgetName)
if (!resolvedTarget) return undefined
return [resolvedTarget.nodeId, resolvedTarget.widgetName]
return JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
/** Manages lifecycle of all subgraph event listeners */
@@ -762,7 +431,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.removeInput(e.detail.index)
this._invalidatePromotedViewsCache()
this._syncPromotions()
this.setDirtyCanvas(true, true)
},
{ signal }
@@ -874,17 +542,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// so resolve by current links would miss this new connection.
// Keep the earliest bound view once present, and only bind from event
// payload when this input has no representative yet.
const nodeId = String(e.detail.node.id)
const source: PromotedWidgetSource = {
sourceNodeId: nodeId,
sourceWidgetName: e.detail.widget.name
}
if (
usePromotionStore().isPromoted(this.rootGraph.id, this.id, source)
) {
usePromotionStore().demote(this.rootGraph.id, this.id, source)
}
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
@@ -907,7 +564,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
e.detail.node
)
this._syncPromotions()
this._invalidatePromotedViewsCache()
},
{ signal }
)
@@ -922,7 +579,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const connectedWidgets = subgraphInput.getConnectedWidgets()
if (connectedWidgets.length > 0) {
this._resolveInputWidget(subgraphInput, input)
this._syncPromotions()
this._invalidatePromotedViewsCache()
return
}
@@ -931,7 +588,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
delete input.pos
delete input.widget
input._widget = undefined
this._syncPromotions()
this._invalidatePromotedViewsCache()
},
{ signal }
)
@@ -1043,6 +700,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
super.configure(info)
this._applyPromotedWidgetValues(info.widgets_values)
}
/**
* Hydrate per-instance promoted widget values into this host's widget value
* store entry. Routing through `PromotedWidgetView.set value` would cascade
* into the shared interior widget, stomping every other SubgraphNode
* instance that references the same shared interior.
*/
private _applyPromotedWidgetValues(
widgetValues: ExportedSubgraphInstance['widgets_values']
): void {
if (!widgetValues) return
// Transient clones created during clipboard duplicate go through
// configure() with `id === -1` before being added to the graph.
// Hydrating under id `-1` would poison `useWidgetValueStore` and
// race with the eventual real instance for ownership of host state.
if (this.id === -1) return
let valueIndex = 0
for (const input of this.inputs) {
const view = input._widget
if (!view || !isPromotedWidgetView(view)) continue
if (valueIndex >= widgetValues.length) return
const value = widgetValues[valueIndex]
if (value !== undefined) view.hydrateHostValue(value)
valueIndex += 1
}
}
override _internalConfigureAfterSlots() {
@@ -1052,50 +737,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// This prevents stale/duplicate serialized inputs from persisting (#9977).
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []
// Clear view cache — forces re-creation on next getter access.
// Do NOT clear properties.proxyWidgets — it was already populated
// from serialized data by super.configure(info) before this runs.
this._promotedViewManager.clear()
this._invalidatePromotedViewsCache()
// Hydrate the store from serialized properties.proxyWidgets
const raw = parseProxyWidgets(this.properties.proxyWidgets)
const store = usePromotionStore()
const entries = raw
.map(([nodeId, widgetName, sourceNodeId]) => {
if (nodeId === '-1') {
const resolved = this._resolveLegacyEntry(widgetName)
if (resolved)
return { sourceNodeId: resolved[0], sourceWidgetName: resolved[1] }
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
)
}
return null
}
if (!this.subgraph.getNodeById(nodeId)) return null
return normalizeLegacyProxyWidgetEntry(
this,
nodeId,
widgetName,
sourceNodeId
)
})
.filter((e): e is NonNullable<typeof e> => e !== null)
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy or stale entries don't persist
const serialized = this._serializeEntries(entries)
if (JSON.stringify(serialized) !== JSON.stringify(raw)) {
this.properties.proxyWidgets = serialized
}
usePreviewExposureStore().setExposures(
this.rootGraph.id,
String(this.id),
parsePreviewExposures(this.properties.previewExposures)
)
// Check all inputs for connected widgets
for (const input of this.inputs) {
@@ -1113,16 +763,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._resolveInputWidget(subgraphInput, input)
}
this._syncPromotions()
this._invalidatePromotedViewsCache()
for (const node of this.subgraph.nodes) {
if (!supportsVirtualCanvasImagePreview(node)) continue
const source: PromotedWidgetSource = {
const hostLocator = String(this.id)
const previewStore = usePreviewExposureStore()
const existing = previewStore
.getExposures(this.rootGraph.id, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === String(node.id) &&
exposure.sourcePreviewName === CANVAS_IMAGE_PREVIEW_WIDGET
)
if (existing) continue
previewStore.addExposure(this.rootGraph.id, hostLocator, {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
store.promote(this.rootGraph.id, this.id, source)
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
}
}
@@ -1144,7 +802,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._resolveInputWidget(subgraphInput, input)
}
this._syncPromotions()
this._invalidatePromotedViewsCache()
}
private _resolveInputWidget(
@@ -1202,14 +860,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
interiorNode: LGraphNode
) {
this._invalidatePromotedViewsCache()
this._flushPendingPromotions()
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
const sourceNodeId =
interiorNode.isSubgraphNode() && isPromotedWidgetView(interiorWidget)
? interiorWidget.sourceNodeId
: undefined
const previousView = input._widget
@@ -1219,34 +872,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(previousView.sourceNodeId !== nodeId ||
previousView.sourceWidgetName !== widgetName)
) {
usePromotionStore().demote(this.rootGraph.id, this.id, previousView)
this._removePromotedView(previousView)
}
if (this.id === -1) {
if (
!this._pendingPromotions.some(
(entry) =>
entry.sourceNodeId === nodeId &&
entry.sourceWidgetName === widgetName &&
entry.disambiguatingSourceNodeId === sourceNodeId
)
) {
this._pendingPromotions.push({
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId })
})
}
} else {
// Add to promotion store
usePromotionStore().promote(this.rootGraph.id, this.id, {
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
disambiguatingSourceNodeId: sourceNodeId
})
}
// Create/retrieve the view from cache.
// The cache key uses `input.name` (the slot's internal name) rather
// than `subgraphInput.name` because nested subgraphs may remap
@@ -1260,15 +888,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
nodeId,
widgetName,
input.label ?? subgraphInput.name,
sourceNodeId,
subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
input.label ?? input.name,
sourceNodeId
input.label ?? input.name
)
)
@@ -1291,19 +917,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
private _flushPendingPromotions() {
if (this.id === -1 || this._pendingPromotions.length === 0) return
for (const entry of this._pendingPromotions) {
usePromotionStore().promote(this.rootGraph.id, this.id, entry)
}
this._pendingPromotions = []
}
override onAdded(_graph: LGraph): void {
this._flushPendingPromotions()
this._syncPromotions()
this._invalidatePromotedViewsCache()
}
/**
@@ -1454,8 +1069,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const resolved = resolveConcretePromotedWidget(
this,
view.sourceNodeId,
view.sourceWidgetName,
view.disambiguatingSourceNodeId
view.sourceWidgetName
)
if (resolved.status !== 'resolved') return
@@ -1484,8 +1098,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,
inputName,
view.disambiguatingSourceNodeId
inputName
)
)
}
@@ -1498,7 +1111,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override ensureWidgetRemoved(widget: IBaseWidget): void {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
usePromotionStore().demote(this.rootGraph.id, this.id, widget)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
@@ -1512,7 +1124,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphNode: this
})
this._syncPromotions()
this._invalidatePromotedViewsCache()
}
override onRemoved(): void {
@@ -1529,7 +1141,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
}
usePromotionStore().setPromotions(this.rootGraph.id, this.id, [])
this._promotedViewManager.clear()
for (const input of this.inputs) {
@@ -1574,36 +1185,72 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
/**
* Synchronizes widget values from this SubgraphNode instance to the
* corresponding widgets in the subgraph definition before serialization.
* This ensures nested subgraph widget values are preserved when saving.
* Serializes this SubgraphNode instance.
*
* After ADR 0009 the canonical owner of each promoted value widget is the
* linked `SubgraphInput` itself; host-overlay values live in
* `widgets_values`, and previews live in `properties.previewExposures`.
* `properties.proxyWidgets` is no longer re-emitted; legacy data is preserved
* for one-way ratchet load only.
*/
override serialize(): ISerialisedNode {
// Sync widget values to subgraph definition before serialization.
// Only sync for inputs that are linked to a promoted widget via _widget.
for (const input of this.inputs) {
if (!input._widget) continue
// TODO(adr-0009): Remove this comment once one stable release has shipped
// without complaints about subgraph value drift. Host promoted-widget
// values now serialize through standard SubgraphInput widgets and must not
// be copied into interior widgets, which would cause cross-host stomping.
const subgraphInput =
input._subgraphSlot ??
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
if (!subgraphInput) continue
const serialized = super.serialize()
const serializedProperties = { ...(serialized.properties ?? {}) }
const rootGraphId = this.rootGraph.id
const hostLocator = String(this.id)
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = input._widget.value
}
const previewExposures = usePreviewExposureStore().getExposures(
rootGraphId,
hostLocator
)
if (previewExposures.length > 0) {
serializedProperties.previewExposures = previewExposures.map((entry) => ({
...entry
}))
} else {
delete serializedProperties.previewExposures
}
// Write promotion store state back to properties for serialization
const entries = usePromotionStore().getPromotions(
this.rootGraph.id,
this.id
)
this.properties.proxyWidgets = this._serializeEntries(entries)
const quarantine = readHostQuarantine(this)
if (quarantine.length === 0) {
delete serializedProperties.proxyWidgetErrorQuarantine
} else {
serializedProperties.proxyWidgetErrorQuarantine = quarantine.map(
(entry) => ({ ...entry })
)
}
return super.serialize()
serialized.properties = serializedProperties
const widgetStore = useWidgetValueStore()
const widgetValues: TWidgetValue[] = []
let hasSerializableValue = false
for (const input of this.inputs) {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) continue
const state = widgetStore.getWidget(
rootGraphId,
this.id,
getPromotedWidgetHostStateName(widget)
)
const value =
state && isWidgetValue(state.value) ? state.value : undefined
widgetValues.push(value)
hasSerializableValue ||= value !== undefined
}
if (hasSerializableValue) serialized.widgets_values = widgetValues
else delete serialized.widgets_values
return serialized
}
override clone() {
const clone = super.clone()

View File

@@ -8,6 +8,7 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
@@ -291,9 +292,9 @@ describe('SubgraphWidgetPromotion', () => {
hostNode.configure(serializedHostNode)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(interiorNode.id), 'batch_size']
])
// ADR 0009: configure() no longer writes resolved entries back to
// properties.proxyWidgets. Hydration is observable via the synthetic
// widget surface instead.
expect(hostNode.widgets).toHaveLength(1)
expect(hostNode.widgets[0].name).toBe('batch_size')
expect(hostNode.widgets[0].value).toBe(1)
@@ -356,7 +357,14 @@ describe('SubgraphWidgetPromotion', () => {
expect(widgetSourceIds).toContain(keptSamplerNodeId)
})
it('should normalize legacy prefixed proxyWidgets on configure', () => {
it('quarantines legacy prefixed proxyWidgets that target a deep leaf widget', () => {
// ADR 0009: each SubgraphNode is opaque. The legacy
// "<nestedId>: <leafId>: <leafWidgetName>" encoding referenced a deep
// leaf widget through nested chain traversal. Under the opaque model
// the migration cannot resolve that identity at the immediate level,
// so the entry is quarantined rather than reconstructed as a
// canonical promoted view. Users with this legacy state must
// re-promote through each subgraph level explicitly.
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
@@ -396,21 +404,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).toHaveLength(0)
expect(hostNode.properties.proxyWidgets).toBeUndefined()
const quarantine = hostNode.properties.proxyWidgetErrorQuarantine
expect(Array.isArray(quarantine) && quarantine.length).toBeGreaterThan(0)
})
it('should preserve promoted widget entries after cloning', () => {
@@ -427,31 +430,21 @@ 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
// ADR 0009: clone preservation no longer relies on properties.proxyWidgets.
// The promoted widgets are derived from the linked SubgraphInputs that
// come through the serialized inputs/links, so the host's own widgets
// getter should expose the promoted view after configure.
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')
})
})

View File

@@ -19,15 +19,16 @@ interface DeduplicationResult {
* 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.
* Also patches legacy proxyWidgets in root-level nodes that reference the
* remapped inner node IDs. The ADR 0009 migration flush consumes these tuples
* after configure.
*
* 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
* @param rootNodes - Optional root-level nodes with legacy proxyWidgets to patch
*/
export function deduplicateSubgraphNodeIds(
subgraphs: ExportedSubgraph[],
@@ -197,7 +198,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

@@ -0,0 +1,53 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
runSubgraphMigrationFlushHook,
setSubgraphMigrationFlushHook
} from './subgraphMigrationHook'
describe('subgraph migration hook registry', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
setSubgraphMigrationFlushHook(undefined)
vi.restoreAllMocks()
})
it('warns in tests when legacy proxyWidgets exist but no flush hook is wired', () => {
const hostNode = createTestSubgraphNode(createTestSubgraph())
hostNode.properties.proxyWidgets = [['1', 'seed']]
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
runSubgraphMigrationFlushHook(hostNode, undefined)
expect(warnSpy).toHaveBeenCalledWith(
'[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired',
expect.objectContaining({
hostNodeId: hostNode.id,
proxyWidgets: [['1', 'seed']]
})
)
})
it('uses the wired flush hook instead of warning', () => {
const hostNode = createTestSubgraphNode(createTestSubgraph())
hostNode.properties.proxyWidgets = [['1', 'seed']]
const hook = vi.fn()
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
setSubgraphMigrationFlushHook(hook)
runSubgraphMigrationFlushHook(hostNode, undefined)
expect(hook).toHaveBeenCalledWith({ hostNode, nodeData: undefined })
expect(warnSpy).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,52 @@
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { SubgraphNode } from './SubgraphNode'
/**
* Late-bound hook that runs after a host graph has finished configuring all
* its nodes and links. Wired in app initialization to {@link
* flushProxyWidgetMigration}; left undefined in tests that exercise
* `LGraph.configure` without the migration pipeline.
*
* The hook is intentionally untyped at the LGraph layer because importing
* the flush directly from LGraph would create a circular dependency through
* the PreviewExposureStore.
*/
type SubgraphMigrationFlushHook = (args: {
hostNode: SubgraphNode
nodeData: ISerialisedNode | undefined
}) => void
interface SubgraphMigrationRegistry {
flush?: SubgraphMigrationFlushHook
}
const registry: SubgraphMigrationRegistry = {}
export function setSubgraphMigrationFlushHook(
hook: SubgraphMigrationFlushHook | undefined
): void {
registry.flush = hook
}
export function runSubgraphMigrationFlushHook(
hostNode: SubgraphNode,
nodeData: ISerialisedNode | undefined
): void {
if (registry.flush) {
registry.flush({ hostNode, nodeData })
return
}
if (hostNode.properties.proxyWidgets === undefined) return
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: hostNode.id,
proxyWidgets: hostNode.properties.proxyWidgets
}
)
}
}

View File

@@ -17,7 +17,6 @@ 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'
@@ -26,7 +25,6 @@ export interface DrawWidgetOptions {
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[]
@@ -206,17 +204,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(_suppressPromotedOutline = false) {
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR

View File

@@ -12,6 +12,7 @@ import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import { wireProxyWidgetMigrationFlush } from '@/core/graph/subgraph/migration/wireProxyWidgetMigrationFlush'
import {
configValueOrDefault,
remoteConfig
@@ -108,6 +109,10 @@ app
modules: [VueFireAuth()]
})
// ADR 0009: hook the proxyWidget migration flush into LGraph.configure.
// Late-bound so the LGraph layer doesn't import the PreviewExposureStore.
wireProxyWidgetMigrationFlush()
const bootstrapStore = useBootstrapStore(pinia)
void bootstrapStore.startStoreBootstrap()

View File

@@ -10,7 +10,6 @@ import {
hasWidgetError,
isWidgetVisible
} from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -207,20 +206,15 @@ describe('computeProcessedWidgets borderStyle', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('applies promoted border styling to intermediate promoted widgets', () => {
it('does not apply border styling to promoted widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
slotName: 'text',
promotedLabel: 'Text'
})
const result = computeProcessedWidgets({
@@ -242,13 +236,12 @@ describe('computeProcessedWidgets borderStyle', () => {
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(true)
expect(result[0].simplified.borderStyle).toBeUndefined()
expect(result[0].simplified.label).toBe('Text')
})
it('does not apply promoted border styling to outermost widgets', () => {
const promotedWidget = createMockWidget({
it('does not apply border styling to regular widgets', () => {
const widget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
@@ -257,17 +250,11 @@ describe('computeProcessedWidgets borderStyle', () => {
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const result = computeProcessedWidgets({
nodeData: {
id: '4',
type: 'SubgraphNode',
widgets: [promotedWidget],
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,

View File

@@ -29,7 +29,6 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -167,7 +166,6 @@ export function computeProcessedWidgets({
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData?.widgets) return []
const promotionStore = usePromotionStore()
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
@@ -249,13 +247,9 @@ export function computeProcessedWidgets({
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
@@ -270,17 +264,9 @@ export function computeProcessedWidgets({
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const borderStyle = mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId

View File

@@ -1,8 +1,10 @@
import { z } from 'zod'
import { LinkMarkerShape } from '@/lib/litegraph/src/litegraph'
import { LinkMarkerShape } from '@/lib/litegraph/src/types/globalEnums'
import { zNodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { colorPalettesSchema } from '@/schemas/colorPaletteSchema'
import { resultItemType } from '@/schemas/resultItemTypeSchema'
import type { ResultItemType } from '@/schemas/resultItemTypeSchema'
import { zKeybinding } from '@/platform/keybindings/types'
import { NodeBadgeMode } from '@/types/nodeSource'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
@@ -10,8 +12,8 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
const zNodeType = z.string()
const zJobId = z.string()
export type JobId = z.infer<typeof zJobId>
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>
export { resultItemType }
export type { ResultItemType }
const zCustomNodesI18n = z.record(z.string(), z.unknown())
export type CustomNodesI18n = z.infer<typeof zCustomNodesI18n>

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
const nodeSlotSchema = z.object({
CLIP: z.string(),
@@ -32,9 +32,9 @@ const litegraphBaseSchema = z.object({
NODE_DEFAULT_BGCOLOR: z.string(),
NODE_DEFAULT_BOXCOLOR: z.string(),
NODE_DEFAULT_SHAPE: z.union([
z.literal(LiteGraph.BOX_SHAPE),
z.literal(LiteGraph.ROUND_SHAPE),
z.literal(LiteGraph.CARD_SHAPE),
z.literal(RenderShape.BOX),
z.literal(RenderShape.ROUND),
z.literal(RenderShape.CARD),
// Legacy palettes have string field for NODE_DEFAULT_SHAPE.
z.string()
]),

View File

@@ -1,7 +1,7 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import { resultItemType } from '@/schemas/apiSchema'
import { resultItemType } from '@/schemas/resultItemTypeSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])

View File

@@ -0,0 +1,4 @@
import { z } from 'zod'
export const resultItemType = z.enum(['input', 'output', 'temp'])
export type ResultItemType = z.infer<typeof resultItemType>

View File

@@ -0,0 +1 @@
export const IS_CONTROL_WIDGET = Symbol()

View File

@@ -3,8 +3,6 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
const isPromotedByAnyMock = vi.hoisted(() => vi.fn())
// Mock dependencies
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
@@ -12,12 +10,6 @@ vi.mock('@/stores/domWidgetStore', () => ({
})
}))
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
isPromotedByAny: isPromotedByAnyMock
})
}))
vi.mock('@/utils/formatUtil', () => ({
generateUUID: () => 'test-uuid'
}))
@@ -114,41 +106,12 @@ describe('DOMWidget Y Position Preservation', () => {
})
})
describe('DOMWidget draw promotion behavior', () => {
describe('DOMWidget draw behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('draws promoted outline for visible promoted widgets', () => {
isPromotedByAnyMock.mockReturnValue(true)
const node = new LGraphNode('test-node')
const rootGraph = { id: 'root-graph-id' }
node.graph = { rootGraph } as never
const onDraw = vi.fn()
const widget = new DOMWidgetImpl({
node,
name: 'seed',
type: 'text',
element: document.createElement('div'),
options: { onDraw }
})
const ctx = createMockContext()
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
sourceNodeId: '-1',
sourceWidgetName: 'seed'
})
expect(ctx.strokeRect).toHaveBeenCalledOnce()
expect(onDraw).toHaveBeenCalledWith(widget)
})
test('does not draw promoted outline when widget is not promoted', () => {
isPromotedByAnyMock.mockReturnValue(false)
test('does not draw an outline for visible widgets', () => {
const node = new LGraphNode('test-node')
const rootGraph = { id: 'root-graph-id' }
node.graph = { rootGraph } as never
@@ -187,7 +150,6 @@ describe('DOMWidget draw promotion behavior', () => {
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).not.toHaveBeenCalled()
expect(ctx.strokeRect).not.toHaveBeenCalled()
expect(onDraw).toHaveBeenCalledWith(widget)
})

View File

@@ -13,7 +13,6 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { generateUUID } from '@/utils/formatUtil'
export interface BaseDOMWidget<
@@ -125,7 +124,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
declare readonly name: string
declare readonly options: DOMWidgetOptions<V>
declare callback?: (value: V) => void
readonly promotionStore = usePromotionStore()
readonly id: string
@@ -186,30 +184,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
this.options.onDraw?.(this)
return
}
const graphId = this.node.graph?.rootGraph.id
const isPromoted =
graphId &&
this.promotionStore.isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
if (!isPromoted) {
this.options.onDraw?.(this)
return
}
ctx.save()
const adjustedMargin = this.margin - 1
ctx.beginPath()
ctx.strokeStyle = LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
ctx.strokeRect(
adjustedMargin,
y + adjustedMargin,
widget_width - adjustedMargin * 2,
(this.computedHeight ?? widget_height) - 2 * adjustedMargin
)
ctx.restore()
}
this.options.onDraw?.(this)
}

View File

@@ -28,9 +28,12 @@ import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from './app'
import { IS_CONTROL_WIDGET } from './controlWidgetMarker'
import './domWidget'
import './errorNodeWidgets'
export { IS_CONTROL_WIDGET }
export type ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpecV2
@@ -77,7 +80,6 @@ export function updateControlWidgetLabel(widget: IBaseWidget) {
}
}
export const IS_CONTROL_WIDGET = Symbol()
const HAS_EXECUTED = Symbol()
export function addValueControlWidget(
@@ -175,6 +177,14 @@ export function addValueControlWidgets(
}
const applyWidgetControl = () => {
if (
node.inputs?.some(
(input) =>
input.widget?.name === targetWidget.name && input.link != null
)
)
return
var v = valueControl.value
if (isCombo && v !== 'fixed') {

View File

@@ -57,7 +57,7 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -187,11 +187,13 @@ export const useLitegraphService = () => {
}
function getPseudoWidgetPreviewTargets(node: SubgraphNode): LGraphNode[] {
const promotionStore = usePromotionStore()
const promotions = promotionStore.getPromotionsRef(
node.rootGraph.id,
node.id
)
const hostLocator = String(node.id)
const promotions = usePreviewExposureStore()
.getExposures(node.rootGraph.id, hostLocator)
.map((exposure) => ({
sourceNodeId: exposure.sourceNodeId,
sourceWidgetName: exposure.sourcePreviewName
}))
const resolved = resolveSubgraphPseudoWidgetCache({
cache: subgraphPseudoWidgetCache.get(node) ?? null,
promotions,

View File

@@ -5,12 +5,14 @@ import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import type { ChangeTracker } from '@/scripts/changeTracker'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import { createNodeLocatorId } from '@/types/nodeIdentification'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
@@ -506,4 +508,138 @@ describe('appModeStore', () => {
)
})
})
// ADR 0009: legacy `(sourceNodeId, sourceWidgetName)` selection tuples
// for promoted widgets must project through the wrapping host SubgraphNode
// into `(hostNodeLocator, subgraphInputName)`. Tuples that cannot be
// projected are dropped with `console.warn`.
describe('legacy selectedInput tuple migration (ADR 0009)', () => {
it('migrates legacy promoted widget selected inputs before node-existence passthrough', () => {
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const hostId = 5
const sourceNodeId = 42
const subgraphInputName = 'Prompt'
const sourceWidgetName = 'text'
const hostWidget = {
name: subgraphInputName,
sourceNodeId: String(sourceNodeId),
sourceWidgetName
}
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
id: hostId,
inputs: [{ name: subgraphInputName, _widget: hostWidget }],
widgets: [hostWidget],
isSubgraphNode: () => true
}) as SubgraphNode
vi.mocked(app.rootGraph).id = rootGraphId
vi.mocked(app.rootGraph).nodes = [hostNode]
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id: NodeId | null | undefined) => (id == hostId ? hostNode : null)
)
mockResolveNode.mockImplementation((id) =>
id == sourceNodeId
? fromAny<LGraphNode, unknown>({ id: sourceNodeId })
: undefined
)
const result = store.pruneLinearData({
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
outputs: []
})
expect(result.inputs).toEqual([
[
createNodeLocatorId(rootGraphId, hostId),
subgraphInputName,
{ height: 120 }
]
])
})
it('keeps a direct root-node widget when its id and name collide with a promoted source', () => {
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const hostId = 5
const sourceNodeId = 42
const sourceWidgetName = 'text'
const rootNode = fromAny<LGraphNode, unknown>({
id: sourceNodeId,
widgets: [{ name: sourceWidgetName }]
})
const hostWidget = {
name: 'Prompt',
sourceNodeId: String(sourceNodeId),
sourceWidgetName
}
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
id: hostId,
inputs: [{ name: 'Prompt', _widget: hostWidget }],
widgets: [hostWidget],
isSubgraphNode: () => true
}) as SubgraphNode
vi.mocked(app.rootGraph).id = rootGraphId
vi.mocked(app.rootGraph).nodes = [rootNode, hostNode]
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id: NodeId | null | undefined) =>
id == sourceNodeId ? rootNode : id == hostId ? hostNode : null
)
mockResolveNode.mockImplementation((id) =>
id == sourceNodeId ? rootNode : undefined
)
const result = store.pruneLinearData({
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
outputs: []
})
expect(result.inputs).toEqual([
[sourceNodeId, sourceWidgetName, { height: 120 }]
])
})
it('warns and drops a tuple whose source node no longer resolves', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
mockResolveNode.mockReturnValue(undefined)
const result = store.pruneLinearData({
inputs: [[42 as NodeId, 'widget-name', { height: 42 }]],
outputs: []
})
expect(result.inputs).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('legacy selectedInput tuple'),
expect.objectContaining({
storedId: 42,
widgetName: 'widget-name'
})
)
warnSpy.mockRestore()
})
it('passes through tuples already in `hostLocator:subgraphInputName` form', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const hostId = 5
const hostLocator = '11111111-1111-4111-8111-111111111111:5'
const hostNode = fromAny<LGraphNode, unknown>({
id: hostId,
isSubgraphNode: () => true,
widgets: [{ name: 'subgraph_input_name' }]
})
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id: NodeId | null | undefined) => (id == hostId ? hostNode : null)
)
const result = store.pruneLinearData({
inputs: [[hostLocator, 'subgraph_input_name']],
outputs: []
})
expect(result.inputs).toEqual([[hostLocator, 'subgraph_input_name']])
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
})
})

View File

@@ -5,6 +5,7 @@ import { useEventListener } from '@vueuse/core'
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
InputWidgetConfig,
LinearData,
@@ -18,7 +19,8 @@ import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { resolveNode } from '@/utils/litegraphUtil'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { resolveNode, resolveNodeWidget } from '@/utils/litegraphUtil'
export function nodeTypeValidForApp(type: string) {
return !['Note', 'MarkdownNote'].includes(type)
@@ -46,13 +48,20 @@ export const useAppModeStore = defineStore('appMode', () => {
// Prune entries referencing nodes deleted in workflow mode.
// Only check node existence, not widgets — dynamic widgets can
// hide/show other widgets so a missing widget does not mean stale data.
// ADR 0009: also performs the one-shot legacy-tuple migration that
// projects pre-ratchet `(sourceNodeId, sourceWidgetName)` selections
// through the new host-scoped `(hostNodeLocator, subgraphInputName)`
// identity. Failed projections are dropped with `console.warn`.
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
const rawInputs = data?.inputs ?? []
const rawOutputs = data?.outputs ?? []
return {
inputs: app.rootGraph
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
? rawInputs
.map(migrateLegacyInputTuple)
.filter((entry): entry is LinearInput => entry !== null)
.filter(selectedInputExists)
: rawInputs,
outputs: app.rootGraph
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
@@ -60,6 +69,86 @@ export const useAppModeStore = defineStore('appMode', () => {
}
}
function selectedInputExists([nodeId, widgetName]: LinearInput): boolean {
if (typeof nodeId === 'string' && nodeId.includes(':')) {
if (typeof app.rootGraph?.getNodeById !== 'function') return true
const [, widget] = resolveNodeWidget(nodeId, widgetName)
return Boolean(widget)
}
return Boolean(resolveNode(nodeId))
}
/**
* If a legacy tuple references the interior `(sourceNodeId, widgetName)`
* of a now-promoted widget, project it through the wrapping host
* SubgraphNode's locator + subgraph-input name.
*/
function migrateLegacyInputTuple(input: LinearInput): LinearInput | null {
const [storedId, widgetName] = input
if (typeof storedId === 'string' && storedId.includes(':')) {
// Already in `(hostNodeLocator, subgraphInputName)` form.
return input
}
if (directRootWidgetExists(storedId, widgetName)) return input
const projection = projectLegacyTupleThroughHost(storedId, widgetName)
if (projection) {
return [projection.hostLocator, projection.subgraphInputName, input[2]]
}
if (resolveNode(storedId)) return input
console.warn(
'[appModeStore] dropping legacy selectedInput tuple — no canonical identity available',
{ storedId, widgetName }
)
return null
}
function directRootWidgetExists(nodeId: NodeId, widgetName: string): boolean {
const node = app.rootGraph?.getNodeById?.(nodeId)
return Boolean(node?.widgets?.some((widget) => widget.name === widgetName))
}
function projectLegacyTupleThroughHost(
legacySourceNodeId: NodeId,
legacyWidgetName: string
): { hostLocator: string; subgraphInputName: string } | null {
const rootGraph = app.rootGraph
if (!rootGraph) return null
const matches: Array<{ hostLocator: string; subgraphInputName: string }> =
[]
for (const node of rootGraph.nodes) {
if (!(node instanceof SubgraphNode)) continue
for (const inputSlot of node.inputs) {
const widget = inputSlot._widget
if (!widget || !isPromotedWidgetView(widget)) continue
if (
widget.sourceNodeId === String(legacySourceNodeId) &&
widget.sourceWidgetName === legacyWidgetName
) {
matches.push({
hostLocator: createNodeLocatorId(rootGraph.id, node.id),
subgraphInputName: inputSlot.name
})
}
}
}
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
console.warn(
'[appModeStore] dropping ambiguous legacy selectedInput tuple',
{ storedId: legacySourceNodeId, widgetName: legacyWidgetName }
)
}
return null
}
function loadSelections(data: Partial<LinearData> | undefined) {
const { inputs, outputs } = pruneLinearData(data)
selectedInputs.value = inputs
@@ -154,10 +243,16 @@ export const useAppModeStore = defineStore('appMode', () => {
}
function removeSelectedInput(widget: IBaseWidget, node: { id: NodeId }) {
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
// ADR 0009: promoted widgets identify by `(hostNodeLocator,
// subgraphInputName)` so that two host SubgraphNodes wrapping the same
// Subgraph definition retain independent selections.
const rootGraphId = app.rootGraph?.id
const isPromoted = isPromotedWidgetView(widget)
const storeId =
isPromoted && rootGraphId
? createNodeLocatorId(rootGraphId, node.id)
: node.id
const storeName = widget.name
const index = selectedInputs.value.findIndex(
([id, name]) => storeId == id && storeName === name
)

View File

@@ -61,6 +61,46 @@ describe('nodeOutputStore setNodeOutputsByExecutionId with merge', () => {
app.nodePreviewImages = {}
})
it('keeps execution-keyed outputs distinct from locator-keyed outputs', () => {
const store = useNodeOutputStore()
const firstOutput = createMockOutputs([{ filename: 'first.png' }])
const secondOutput = createMockOutputs([{ filename: 'second.png' }])
store.setNodeOutputsByExecutionId('11:20:10', firstOutput)
store.setNodeOutputsByExecutionId('12:20:10', secondOutput)
expect(store.getNodeOutputByExecutionId('11:20:10')).toEqual(firstOutput)
expect(store.getNodeOutputByExecutionId('12:20:10')).toEqual(secondOutput)
})
it('merges execution-keyed outputs when merge is true', () => {
const store = useNodeOutputStore()
const initialOutput = createMockOutputs([{ filename: 'first.png' }])
const nextOutput = createMockOutputs([{ filename: 'second.png' }])
store.setNodeOutputsByExecutionId('11:20:10', initialOutput)
store.setNodeOutputsByExecutionId('11:20:10', nextOutput, { merge: true })
expect(store.getNodeOutputByExecutionId('11:20:10')?.images).toEqual([
{ filename: 'first.png' },
{ filename: 'second.png' }
])
})
it('keeps execution-keyed previews distinct from locator-keyed previews', () => {
const store = useNodeOutputStore()
store.setNodePreviewsByExecutionId('11:20:10', ['blob:first'])
store.setNodePreviewsByExecutionId('12:20:10', ['blob:second'])
expect(store.getNodePreviewImagesByExecutionId('11:20:10')).toEqual([
'blob:first'
])
expect(store.getNodePreviewImagesByExecutionId('12:20:10')).toEqual([
'blob:second'
])
})
it('should update reactive nodeOutputs.value when merging outputs', () => {
const store = useNodeOutputStore()
const executionId = '1'
@@ -301,6 +341,14 @@ describe('nodeOutputStore getPreviewParam', () => {
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
})
it('should return empty string if outputs.images only contains null entries', () => {
const store = useNodeOutputStore()
const node = createMockNode()
const outputs = createMockOutputs(fromAny([null]))
expect(store.getPreviewParam(node, outputs)).toBe('')
expect(vi.mocked(app).getPreviewFormatParam).not.toHaveBeenCalled()
})
it('should return empty string if outputs.images contains SVG images', () => {
const store = useNodeOutputStore()
const node = createMockNode()

View File

@@ -63,11 +63,15 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
const nodeOutputs = ref<Record<string, ExecutedWsMessage['output']>>({})
const nodeOutputsByExecutionId = ref<
Record<string, ExecutedWsMessage['output']>
>({})
// Reactive state for node preview images - mirrors app.nodePreviewImages
const nodePreviewImages = ref<Record<string, string[]>>(
app.nodePreviewImages || {}
)
const nodePreviewImagesByExecutionId = ref<Record<string, string[]>>({})
function getNodeOutputs(
node: LGraphNode
@@ -93,9 +97,11 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// If no images, return false
if (!outputs?.images?.length) return false
const images = outputs.images.filter((image) => image != null)
if (!images.length) return false
// If svg images, return false
if (outputs.images.some((image) => image.filename?.endsWith('svg')))
return false
if (images.some((image) => image.filename?.endsWith('svg'))) return false
return true
}
@@ -124,10 +130,85 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
return outputs.images.map((image) => {
const params = new URLSearchParams(image)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
return outputs.images
.filter((image) => image != null)
.map((image) => {
const params = new URLSearchParams(image)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}
function getNodeOutputByExecutionId(
executionId: string
): ExecutedWsMessage['output'] | undefined {
return nodeOutputsByExecutionId.value[executionId]
}
function getNodePreviewImagesByExecutionId(
executionId: string
): string[] | undefined {
return nodePreviewImagesByExecutionId.value[executionId]
}
function getNodeImageUrlsByExecutionId(
executionId: string,
node: LGraphNode
): string[] | undefined {
const previews = getNodePreviewImagesByExecutionId(executionId)
if (previews?.length) return previews
const outputs = getNodeOutputByExecutionId(executionId)
if (!outputs?.images?.length) return
const rand = app.getRandParam()
const previewParam = getPreviewParam(node, outputs)
return outputs.images
.filter((image) => image != null)
.map((image) => {
const params = new URLSearchParams(image)
return api.apiURL(`/view?${params}${previewParam}${rand}`)
})
}
function setExecutionPreviews(executionId: string, previewImages: string[]) {
const existingPreviews = nodePreviewImagesByExecutionId.value[executionId]
if (existingPreviews?.[Symbol.iterator]) {
for (const url of existingPreviews) {
releaseSharedObjectUrl(url)
}
}
for (const url of previewImages) {
retainSharedObjectUrl(url)
}
nodePreviewImagesByExecutionId.value[executionId] = previewImages
}
function revokeExecutionPreviews(executionId: string) {
const previews = nodePreviewImagesByExecutionId.value[executionId]
if (!previews?.[Symbol.iterator]) return
for (const url of previews) {
releaseSharedObjectUrl(url)
}
delete nodePreviewImagesByExecutionId.value[executionId]
}
function mergeOutputRecords(
existingOutput: ExecutedWsMessage['output'],
outputs: ExecutedWsMessage['output'] | ResultItem
): ExecutedWsMessage['output'] {
const merged = { ...existingOutput }
for (const k in outputs) {
const existingValue = merged[k]
const newValue = (outputs as Record<NodeLocatorId, unknown>)[k]
if (Array.isArray(existingValue) && Array.isArray(newValue)) {
merged[k] = existingValue.concat(newValue)
} else {
merged[k] = newValue
}
}
return merged
}
/**
@@ -242,6 +323,14 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
outputs: ExecutedWsMessage['output'] | ResultItem,
options: SetOutputOptions = {}
) {
if (outputs != null) {
const existingOutput = nodeOutputsByExecutionId.value[executionId]
nodeOutputsByExecutionId.value[executionId] =
options.merge && existingOutput
? mergeOutputRecords(existingOutput, outputs)
: outputs
}
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (!nodeLocatorId) return
@@ -259,6 +348,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
executionId: string,
previewImages: string[]
) {
setExecutionPreviews(executionId, previewImages)
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (!nodeLocatorId) return
setNodePreviewsByLocatorId(nodeLocatorId, previewImages)
@@ -310,6 +401,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
* @param executionId - The execution ID
*/
function revokePreviewsByExecutionId(executionId: string) {
revokeExecutionPreviews(executionId)
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
if (!nodeLocatorId) return
scheduleRevoke(nodeLocatorId, () =>
@@ -350,6 +443,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
app.nodePreviewImages = {}
nodePreviewImages.value = {}
nodePreviewImagesByExecutionId.value = {}
}
/**
@@ -441,6 +535,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function resetAllOutputsAndPreviews() {
app.nodeOutputs = {}
nodeOutputs.value = {}
nodeOutputsByExecutionId.value = {}
revokeAllPreviews()
}
@@ -474,6 +569,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// Getters
getNodeOutputs,
getNodeImageUrls,
getNodeImageUrlsByExecutionId,
getNodeOutputByExecutionId,
getNodePreviewImagesByExecutionId,
getNodePreviews,
getPreviewParam,
@@ -499,7 +597,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// State
nodeOutputs,
nodeOutputsByExecutionId,
nodePreviewImages,
nodePreviewImagesByExecutionId,
latestPreview
}
})

View File

@@ -0,0 +1,318 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { usePreviewExposureStore } from './previewExposureStore'
describe(usePreviewExposureStore, () => {
let store: ReturnType<typeof usePreviewExposureStore>
const rootGraphA = 'root-graph-a' as UUID
const rootGraphB = 'root-graph-b' as UUID
const hostA = '7'
const hostB = '8'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = usePreviewExposureStore()
})
describe('getExposures', () => {
it('returns empty readonly array for unknown host', () => {
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
})
})
describe('addExposure', () => {
it('appends a new exposure and returns it with name = sourcePreviewName when no collision', () => {
const entry = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
})
expect(entry).toEqual({
name: '$$canvas-image-preview',
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
})
expect(store.getExposures(rootGraphA, hostA)).toEqual([entry])
})
it('disambiguates name collisions via nextUniqueName', () => {
const first = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
const second = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '43',
sourcePreviewName: 'preview'
})
const third = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '44',
sourcePreviewName: 'preview'
})
expect(first.name).toBe('preview')
expect(second.name).toBe('preview_1')
expect(third.name).toBe('preview_2')
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'preview',
'preview_1',
'preview_2'
])
})
})
describe('setExposures', () => {
it('replaces the array for the host', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
const next = [
{
name: 'replaced',
sourceNodeId: '99',
sourcePreviewName: 'other'
}
]
store.setExposures(rootGraphA, hostA, next)
expect(store.getExposures(rootGraphA, hostA)).toEqual(next)
})
it('clears the host bucket when given an empty array', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
store.setExposures(rootGraphA, hostA, [])
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
})
})
describe('removeExposure', () => {
beforeEach(() => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '43',
sourcePreviewName: 'preview'
})
})
it('removes the matching entry by name', () => {
store.removeExposure(rootGraphA, hostA, 'preview_1')
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'preview'
])
})
it('is a no-op when no entry matches', () => {
const before = store.getExposures(rootGraphA, hostA)
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
})
})
describe('moveExposure', () => {
beforeEach(() => {
store.setExposures(rootGraphA, hostA, [
{ name: 'a', sourceNodeId: '1', sourcePreviewName: 'a' },
{ name: 'b', sourceNodeId: '2', sourcePreviewName: 'b' },
{ name: 'c', sourceNodeId: '3', sourcePreviewName: 'c' }
])
})
it('reorders entries from -> to', () => {
store.moveExposure(rootGraphA, hostA, 0, 2)
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'b',
'c',
'a'
])
})
it('is a no-op for equal indices', () => {
store.moveExposure(rootGraphA, hostA, 1, 1)
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'a',
'b',
'c'
])
})
it('is a no-op for out-of-bounds indices', () => {
store.moveExposure(rootGraphA, hostA, -1, 2)
store.moveExposure(rootGraphA, hostA, 0, 5)
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
'a',
'b',
'c'
])
})
})
describe('clearGraph', () => {
it('removes all hosts under the rootGraphId without affecting others', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '1',
sourcePreviewName: 'p'
})
store.addExposure(rootGraphA, hostB, {
sourceNodeId: '2',
sourcePreviewName: 'p'
})
const hostInB = '7'
store.addExposure(rootGraphB, hostInB, {
sourceNodeId: '3',
sourcePreviewName: 'p'
})
store.clearGraph(rootGraphA)
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
expect(store.getExposures(rootGraphA, hostB)).toEqual([])
expect(store.getExposures(rootGraphB, hostInB)).toHaveLength(1)
})
})
describe('isolation between (rootGraphId, hostNodeLocator) pairs', () => {
it('keeps separate buckets per host and per root graph', () => {
store.addExposure(rootGraphA, hostA, {
sourceNodeId: '1',
sourcePreviewName: 'p'
})
store.addExposure(rootGraphA, hostB, {
sourceNodeId: '2',
sourcePreviewName: 'p'
})
const hostInB = '7'
store.addExposure(rootGraphB, hostInB, {
sourceNodeId: '3',
sourcePreviewName: 'p'
})
expect(store.getExposures(rootGraphA, hostA)).toHaveLength(1)
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
expect(store.getExposures(rootGraphB, hostInB)).toHaveLength(1)
expect(store.getExposures(rootGraphA, hostA)[0].sourceNodeId).toBe('1')
expect(store.getExposures(rootGraphA, hostB)[0].sourceNodeId).toBe('2')
expect(store.getExposures(rootGraphB, hostInB)[0].sourceNodeId).toBe('3')
})
})
describe('resolveChain', () => {
it('returns a single-step chain for an existing exposure when no resolver is provided', () => {
const entry = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
const result = store.resolveChain(rootGraphA, hostA, entry.name)
expect(result?.steps).toHaveLength(1)
expect(result?.steps[0]).toMatchObject({
rootGraphId: rootGraphA,
hostNodeLocator: hostA,
exposure: {
name: 'preview',
sourceNodeId: '42',
sourcePreviewName: 'preview'
}
})
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: '42',
sourcePreviewName: 'preview'
})
})
it('returns undefined when the named exposure is missing', () => {
expect(store.resolveChain(rootGraphA, hostA, 'absent')).toBeUndefined()
})
it('walks one nested host when a resolver is provided', () => {
const innerHost = '99'
store.addExposure(rootGraphA, innerHost, {
sourceNodeId: 'inner-leaf',
sourcePreviewName: 'inner-preview'
})
const outer = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '99',
sourcePreviewName: 'inner-preview'
})
const resolved = store.resolveChain(
rootGraphA,
hostA,
outer.name,
(rootGraphId, hostLocator, sourceNodeId) => {
if (hostLocator === hostA && sourceNodeId === '99') {
return { rootGraphId, hostNodeLocator: innerHost }
}
return undefined
}
)
expect(resolved?.steps).toHaveLength(2)
expect(resolved?.steps[0].hostNodeLocator).toBe(hostA)
expect(resolved?.steps[1].hostNodeLocator).toBe(innerHost)
expect(resolved?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: 'inner-leaf',
sourcePreviewName: 'inner-preview'
})
})
it('walks two nested hosts (three-step chain)', () => {
const inner = '50'
const innermost = '60'
store.addExposure(rootGraphA, innermost, {
sourceNodeId: 'leaf',
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(rootGraphA, inner, {
sourceNodeId: '60',
sourcePreviewName: '$$canvas-image-preview'
})
const outer = store.addExposure(rootGraphA, hostA, {
sourceNodeId: '50',
sourcePreviewName: '$$canvas-image-preview'
})
const resolved = store.resolveChain(
rootGraphA,
hostA,
outer.name,
(rootGraphId, hostLocator, sourceNodeId) => {
if (hostLocator === hostA && sourceNodeId === '50')
return { rootGraphId, hostNodeLocator: inner }
if (hostLocator === inner && sourceNodeId === '60')
return { rootGraphId, hostNodeLocator: innermost }
return undefined
}
)
expect(resolved?.steps).toHaveLength(3)
expect(resolved?.steps.map((s) => s.hostNodeLocator)).toEqual([
hostA,
inner,
innermost
])
expect(resolved?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: 'leaf',
sourcePreviewName: '$$canvas-image-preview'
})
})
})
})

View File

@@ -0,0 +1,153 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { PreviewExposureChainContext } from '@/core/graph/subgraph/preview/previewExposureChain'
import { resolvePreviewExposureChain } from '@/core/graph/subgraph/preview/previewExposureChain'
import type { ResolvedPreviewChain } from '@/core/graph/subgraph/preview/previewExposureTypes'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
const EMPTY_EXPOSURES: readonly PreviewExposure[] = Object.freeze([])
/**
* Optional resolver passed by callers that want {@link resolveChain} to walk
* nested subgraph host boundaries.
*/
export type ResolveNestedHostFn = NonNullable<
PreviewExposureChainContext['resolveNestedHost']
>
export const usePreviewExposureStore = defineStore('previewExposure', () => {
// Host ids are execution paths like `11` or `11:20`, not NodeLocatorIds.
const exposures = ref(new Map<UUID, Map<string, PreviewExposure[]>>())
function _getHostsForGraph(
rootGraphId: UUID
): Map<string, PreviewExposure[]> {
const hosts = exposures.value.get(rootGraphId)
if (hosts) return hosts
const nextHosts = new Map<string, PreviewExposure[]>()
exposures.value.set(rootGraphId, nextHosts)
return nextHosts
}
function _getExposuresRef(
rootGraphId: UUID,
hostNodeLocator: string
): PreviewExposure[] | undefined {
return exposures.value.get(rootGraphId)?.get(hostNodeLocator)
}
function getExposures(
rootGraphId: UUID,
hostNodeLocator: string
): readonly PreviewExposure[] {
return _getExposuresRef(rootGraphId, hostNodeLocator) ?? EMPTY_EXPOSURES
}
function setExposures(
rootGraphId: UUID,
hostNodeLocator: string,
next: readonly PreviewExposure[]
): void {
const hosts = _getHostsForGraph(rootGraphId)
if (next.length === 0) {
hosts.delete(hostNodeLocator)
if (hosts.size === 0) exposures.value.delete(rootGraphId)
return
}
hosts.set(hostNodeLocator, [...next])
}
function addExposure(
rootGraphId: UUID,
hostNodeLocator: string,
source: { sourceNodeId: string; sourcePreviewName: string }
): PreviewExposure {
const hosts = _getHostsForGraph(rootGraphId)
const current = hosts.get(hostNodeLocator) ?? []
const existingNames = current.map((e) => e.name)
const name = nextUniqueName(source.sourcePreviewName, existingNames)
const entry: PreviewExposure = {
name,
sourceNodeId: source.sourceNodeId,
sourcePreviewName: source.sourcePreviewName
}
hosts.set(hostNodeLocator, [...current, entry])
return entry
}
function removeExposure(
rootGraphId: UUID,
hostNodeLocator: string,
name: string
): void {
const current = _getExposuresRef(rootGraphId, hostNodeLocator)
if (!current?.length) return
const next = current.filter((e) => e.name !== name)
if (next.length === current.length) return
setExposures(rootGraphId, hostNodeLocator, next)
}
function moveExposure(
rootGraphId: UUID,
hostNodeLocator: string,
fromIndex: number,
toIndex: number
): void {
const hosts = exposures.value.get(rootGraphId)
const current = hosts?.get(hostNodeLocator)
if (!hosts || !current?.length) return
if (
fromIndex < 0 ||
fromIndex >= current.length ||
toIndex < 0 ||
toIndex >= current.length ||
fromIndex === toIndex
)
return
const next = [...current]
const [entry] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, entry)
hosts.set(hostNodeLocator, next)
}
function clearGraph(rootGraphId: UUID): void {
exposures.value.delete(rootGraphId)
}
/**
* Resolve the chain of exposures from a host down to the originating source
* preview, optionally walking through nested subgraph hosts.
*
* @param resolveNestedHost If provided, the walker recurses through nested
* SubgraphNode boundaries by calling this resolver. Without it, the chain is
* a single-step walk on the starting host.
*/
function resolveChain(
rootGraphId: UUID,
hostNodeLocator: string,
name: string,
resolveNestedHost?: ResolveNestedHostFn
): ResolvedPreviewChain | undefined {
const ctx: PreviewExposureChainContext = {
getExposures,
resolveNestedHost: resolveNestedHost ?? (() => undefined)
}
return resolvePreviewExposureChain(rootGraphId, hostNodeLocator, name, ctx)
}
return {
getExposures,
setExposures,
addExposure,
removeExposure,
moveExposure,
clearGraph,
resolveChain
}
})

View File

@@ -1,892 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from './promotionStore'
describe(usePromotionStore, () => {
let store: ReturnType<typeof usePromotionStore>
const graphA = 'graph-a' as UUID
const graphB = 'graph-b' as UUID
const nodeId = 1 as NodeId
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = usePromotionStore()
})
describe('getPromotions', () => {
it('returns empty array for unknown node', () => {
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('returns a stable empty ref for unknown node', () => {
const first = store.getPromotionsRef(graphA, nodeId)
const second = store.getPromotionsRef(graphA, nodeId)
expect(second).toBe(first)
})
it('returns entries after setPromotions', () => {
const entries = [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
]
store.setPromotions(graphA, nodeId, entries)
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
})
it('returns a defensive copy', () => {
store.setPromotions(graphA, nodeId, [
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
const result = store.getPromotions(graphA, nodeId)
result.push({ sourceNodeId: '11', sourceWidgetName: 'steps' })
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
})
describe('isPromoted', () => {
it('returns false when nothing is promoted', () => {
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true for a promoted entry', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false for a different widget on the same node', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('isPromotedByAny', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('returns false when nothing is promoted', () => {
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true when promoted by one parent', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns true when promoted by multiple parents', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false after demoting from all parents', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true when still promoted by one parent after partial demote', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false for different widget on same node', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('setPromotions', () => {
it('replaces existing entries', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.setPromotions(graphA, nodeId, [
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
it('clears entries when set to empty array', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.setPromotions(graphA, nodeId, [])
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('preserves order', () => {
const entries = [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
]
store.setPromotions(graphA, nodeId, entries)
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
})
})
describe('promote', () => {
it('adds a new entry', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('does not duplicate existing entries', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('appends to existing entries', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
})
})
describe('demote', () => {
it('removes an existing entry', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('is a no-op for non-existent entries', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeId, {
sourceNodeId: '99',
sourceWidgetName: 'nonexistent'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('preserves other entries', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.demote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
})
})
describe('movePromotion', () => {
it('moves an entry from one index to another', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.promote(graphA, nodeId, {
sourceNodeId: '12',
sourceWidgetName: 'cfg'
})
store.movePromotion(graphA, nodeId, 0, 2)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' },
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
it('is a no-op for out-of-bounds indices', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.movePromotion(graphA, nodeId, 0, 5)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
it('is a no-op when fromIndex equals toIndex', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.movePromotion(graphA, nodeId, 1, 1)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
})
})
describe('ref-counted isPromotedByAny', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('tracks across setPromotions calls', () => {
store.setPromotions(graphA, nodeA, [
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
store.setPromotions(graphA, nodeB, [
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
// Remove from A — still promoted by B
store.setPromotions(graphA, nodeA, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
// Remove from B — now gone
store.setPromotions(graphA, nodeB, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('handles replacement via setPromotions correctly', () => {
store.setPromotions(graphA, nodeA, [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
// Replace with different entries
store.setPromotions(graphA, nodeA, [
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '12',
sourceWidgetName: 'cfg'
})
).toBe(true)
})
it('stays consistent through movePromotion', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.movePromotion(graphA, nodeA, 0, 1)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
describe('multi-node isolation', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('keeps promotions separate per subgraph node', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '20',
sourceWidgetName: 'cfg'
})
expect(store.getPromotions(graphA, nodeA)).toEqual([
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.getPromotions(graphA, nodeB)).toEqual([
{ sourceNodeId: '20', sourceWidgetName: 'cfg' }
])
})
it('demoting from one node does not affect another', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromoted(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
})
describe('clearGraph resets ref counts', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('resets isPromotedByAny after clearGraph', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.clearGraph(graphA)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('setPromotions idempotency', () => {
it('does not double ref counts when called twice with same entries', () => {
const entries = [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
]
store.setPromotions(graphA, nodeId, entries)
store.setPromotions(graphA, nodeId, entries)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.setPromotions(graphA, nodeId, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('promote/demote interleaved with setPromotions', () => {
it('maintains consistent ref counts through mixed operations', () => {
const nodeA = 1 as NodeId
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
store.setPromotions(graphA, nodeA, [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
describe('graph isolation', () => {
it('isolates promotions by graph id', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphB, nodeId, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.getPromotions(graphB, nodeId)).toEqual([
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
])
})
it('clearGraph only removes one graph namespace', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphB, nodeId, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
store.clearGraph(graphA)
expect(store.getPromotions(graphA, nodeId)).toEqual([])
expect(store.getPromotions(graphB, nodeId)).toEqual([
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphB, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
describe('sourceNodeId disambiguation', () => {
it('promote with disambiguatingSourceNodeId is found by matching isPromoted', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
).toBe(true)
})
it('isPromoted with different disambiguatingSourceNodeId returns false', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '88'
})
).toBe(false)
})
it('isPromoted with undefined disambiguatingSourceNodeId does not match entry with disambiguatingSourceNodeId', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text'
})
).toBe(false)
})
it('two entries with same sourceNodeId/sourceWidgetName but different disambiguatingSourceNodeId coexist', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(true)
})
it('demote with disambiguatingSourceNodeId removes only matching entry', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
store.demote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(true)
})
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text'
})
).toBe(false)
})
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
store.setPromotions(graphA, nodeA, [
{
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
}
])
store.setPromotions(graphA, nodeB, [
{
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
}
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
store.setPromotions(graphA, nodeA, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
store.setPromotions(graphA, nodeB, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})
})

View File

@@ -1,204 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
return source.disambiguatingSourceNodeId
? `${base}:${source.disambiguatingSourceNodeId}`
: base
}
export const usePromotionStore = defineStore('promotion', () => {
const graphPromotions = ref(
new Map<UUID, Map<NodeId, PromotedWidgetSource[]>>()
)
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
function _getPromotionsForGraph(
graphId: UUID
): Map<NodeId, PromotedWidgetSource[]> {
const promotions = graphPromotions.value.get(graphId)
if (promotions) return promotions
const nextPromotions = new Map<NodeId, PromotedWidgetSource[]>()
graphPromotions.value.set(graphId, nextPromotions)
return nextPromotions
}
function _getRefCountsForGraph(graphId: UUID): Map<string, number> {
const refCounts = graphRefCounts.value.get(graphId)
if (refCounts) return refCounts
const nextRefCounts = new Map<string, number>()
graphRefCounts.value.set(graphId, nextRefCounts)
return nextRefCounts
}
function _incrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = makePromotionEntryKey(e)
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
}
}
function _decrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = makePromotionEntryKey(e)
const count = (refCounts.get(key) ?? 1) - 1
if (count <= 0) {
refCounts.delete(key)
} else {
refCounts.set(key, count)
}
}
}
function getPromotionsRef(
graphId: UUID,
subgraphNodeId: NodeId
): PromotedWidgetSource[] {
return (
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
)
}
function getPromotions(
graphId: UUID,
subgraphNodeId: NodeId
): PromotedWidgetSource[] {
return [...getPromotionsRef(graphId, subgraphNodeId)]
}
function isPromoted(
graphId: UUID,
subgraphNodeId: NodeId,
source: PromotedWidgetSource
): boolean {
return getPromotionsRef(graphId, subgraphNodeId).some(
(e) =>
e.sourceNodeId === source.sourceNodeId &&
e.sourceWidgetName === source.sourceWidgetName &&
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
}
function isPromotedByAny(
graphId: UUID,
source: PromotedWidgetSource
): boolean {
const refCounts = _getRefCountsForGraph(graphId)
return (refCounts.get(makePromotionEntryKey(source)) ?? 0) > 0
}
function setPromotions(
graphId: UUID,
subgraphNodeId: NodeId,
entries: PromotedWidgetSource[]
): void {
const promotions = _getPromotionsForGraph(graphId)
const oldEntries = promotions.get(subgraphNodeId) ?? []
_decrementKeys(graphId, oldEntries)
_incrementKeys(graphId, entries)
if (entries.length === 0) {
promotions.delete(subgraphNodeId)
} else {
promotions.set(subgraphNodeId, [...entries])
}
}
function promote(
graphId: UUID,
subgraphNodeId: NodeId,
source: PromotedWidgetSource
): void {
if (isPromoted(graphId, subgraphNodeId, source)) return
const entries = getPromotionsRef(graphId, subgraphNodeId)
const entry: PromotedWidgetSource = {
sourceNodeId: source.sourceNodeId,
sourceWidgetName: source.sourceWidgetName
}
if (source.disambiguatingSourceNodeId)
entry.disambiguatingSourceNodeId = source.disambiguatingSourceNodeId
setPromotions(graphId, subgraphNodeId, [...entries, entry])
}
function demote(
graphId: UUID,
subgraphNodeId: NodeId,
source: PromotedWidgetSource
): void {
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(
graphId,
subgraphNodeId,
entries.filter(
(e) =>
!(
e.sourceNodeId === source.sourceNodeId &&
e.sourceWidgetName === source.sourceWidgetName &&
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
)
)
}
function movePromotion(
graphId: UUID,
subgraphNodeId: NodeId,
fromIndex: number,
toIndex: number
): void {
const promotions = _getPromotionsForGraph(graphId)
const currentEntries = promotions.get(subgraphNodeId)
if (!currentEntries?.length) return
const entries = [...currentEntries]
if (
fromIndex < 0 ||
fromIndex >= entries.length ||
toIndex < 0 ||
toIndex >= entries.length ||
fromIndex === toIndex
)
return
const [entry] = entries.splice(fromIndex, 1)
entries.splice(toIndex, 0, entry)
promotions.set(subgraphNodeId, entries)
}
function clearGraph(graphId: UUID): void {
graphPromotions.value.delete(graphId)
graphRefCounts.value.delete(graphId)
}
return {
getPromotionsRef,
getPromotions,
isPromoted,
isPromotedByAny,
setPromotions,
promote,
demote,
movePromotion,
clearGraph
}
})

View File

@@ -26,6 +26,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { t } from '@/i18n'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
@@ -333,6 +334,17 @@ export function resolveNodeWidget(
widgetName?: string,
graph: LGraph = app.rootGraph
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
if (widgetName && typeof nodeId === 'string') {
const locator = parseNodeLocatorId(nodeId)
if (locator?.subgraphUuid) {
const host = graph.getNodeById(locator.localNodeId)
if (host?.isSubgraphNode()) {
const widget = host.widgets?.find((w) => w.name === widgetName)
return widget ? [host, widget] : []
}
}
}
const node = graph.getNodeById(nodeId)
if (!widgetName) return node ? [node] : []
if (node) {

View File

@@ -128,21 +128,6 @@ describe('renameWidget', () => {
expect(promotedWidget.label).toBe('Renamed')
})
it('updates _subgraphSlot.label when input has a subgraph slot', () => {
const widget = makeWidget({ name: 'seed' })
const subgraphSlot = { label: undefined as string | undefined }
const input = fromAny<INodeInputSlot, unknown>({
name: 'seed',
widget: { name: 'seed' },
_subgraphSlot: subgraphSlot
})
const node = makeNode({ inputs: [input] })
renameWidget(widget, node, 'New Label')
expect(subgraphSlot.label).toBe('New Label')
})
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
const promotedWidget = makeWidget({
name: 'seed',

View File

@@ -1,7 +1,6 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ISubgraphInput } from '@/lib/litegraph/src/interfaces'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -78,11 +77,6 @@ export function renameWidget(
widget.label = newLabel || undefined
if (input) {
input.label = newLabel || undefined
const subgraphSlot = (input as Partial<ISubgraphInput>)._subgraphSlot
if (subgraphSlot) {
subgraphSlot.label = newLabel || undefined
}
}
// Fires for all node types; listeners guard against non-subgraph nodes.