Compare commits

...

24 Commits

Author SHA1 Message Date
DrJKL
ba7a45239a Don't mix "clean" getter with get accessor 2026-05-04 22:09:47 -07:00
DrJKL
b8bb67bcc7 remove silly test 2026-05-04 19:27:52 -07:00
Alexander Brown
9e7ea6a9d9 Update browser_tests/tests/subgraph/subgraphSerialization.spec.ts
Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-04 19:11:23 -07:00
DrJKL
83b2104983 test: e2e regression for promoted-widget sparse-override (PR #11811)
Adds a Playwright spec that pins PR #11811 (commit 66c89c8e5):

- Loads value-application.json fixture: outer SubgraphNode (id=4,
  widgets_values=["exterior"], proxyWidgets=[["2", "value"]]) over an
  interior PrimitiveNode (id=2, widgets_values=["interior"]) feeding
  PrimitiveStringMultiline (id=1) into PreviewAny (id=3). PrimitiveNode
  is the lazy-widget-creation case from src/extensions/core/widgetInputs.ts
  that triggered the original bug.

- Test 1: Vue node UI for the SubgraphNode renders the promoted "value"
  textbox with "exterior", not "interior". Pins the load-time deferred
  replay in SubgraphNode.onAfterGraphConfigured against PrimitiveNode's
  onAfterGraphConfigured race.

- Test 2: graphToPrompt's getExecutableWidgetValue ancestor walk picks
  up the per-instance override during prompt-build — the
  PrimitiveStringMultiline's `inputs.value` in the api prompt resolves
  to "exterior".

- Test 3: editing the promoted textbox to "edited" propagates through
  PromotedWidgetView setter write-through; api prompt reflects "edited".

- Test 4: reloading the workflow without saving resets the promoted
  widget back to the exterior override.

Fixture under browser_tests/assets/subgraphs/, spec under
browser_tests/tests/subgraph/, follows existing patterns from
subgraphMultiInstanceVueWidgets.spec.ts and subgraphSerialization.spec.ts.

Amp-Thread-ID: https://ampcode.com/threads/T-019df5d3-9cb4-72ed-892e-2e2eb28a6b52
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 19:02:28 -07:00
DrJKL
66c89c8e52 fix: propagate promoted-widget overrides to interior on subgraph load
Sparse-override semantics for PromotedWidgetView:

- SubgraphNode.onAfterGraphConfigured: deferred replay walks promoted
  views and re-applies view.value = perInstanceOverrideValue after
  lazy-creation interiors (e.g. PrimitiveNode in widgetInputs.ts) have
  materialized their widgets and reapplied their widgets_values. Wins
  both the materialization race and the post-restore clobber.
- PromotedWidgetView.set value: per-instance override write +
  write-through to interior widget via resolveAtHost. Chains through
  nested promoted views down to the concrete interior widget.
- PromotedWidgetView.get value: removed eager seed. Sparse-override
  semantics — an override exists only when explicitly written or
  hydrated by configure replay; otherwise reads fall through to the
  live interior widget.
- executionUtil.graphToPrompt: getExecutableWidgetValue walks the
  ancestor SubgraphNode chain and returns the per-instance override
  when one applies, mirroring Vue/canvas read semantics so prompt-build
  does not desync from on-screen values.
- useProcessedWidgets: fallback widget-state lookup so freshly-created
  promoted views read the interior WidgetState entry until they acquire
  their own override.

Tests:

- New promotedWidgetView.lazyInterior.test.ts pins the lazy-widget-
  creation race using a LazyPrimitiveLikeNode fixture.
- executionUtil.test.ts gains an integration test for graphToPrompt
  with the same lazy interior.
- 9 existing tests across promotedWidgetView.test.ts (6),
  subgraphNodePromotion.test.ts (2), and SubgraphNode.multiInstance
  .test.ts (1) updated to reflect the new design intent: exterior
  writes propagate to interior; sparse-override means fresh sibling
  instances follow the shared interior until they acquire an explicit
  override.

Terminology cleanup: replaced informal "cell" vocabulary with
"per-instance override" / "WidgetState entry" / "store entry" across
production code, tests, and investigation notes.

Amp-Thread-ID: https://ampcode.com/threads/T-019df5c0-ff16-718d-8b75-ed15a7c390c7
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 18:48:45 -07:00
DrJKL
676a392187 fix(subgraph): address PR review follow-ups
- useGraphNodeManager: guard String(undefined) on promoted source node id
- SubgraphNode.serialize: preserve null widget values (drop ?? undefined)
- SubgraphNode: trim verbose JSDoc on _pendingWidgetsValuesReplay
- promotedWidgetView.findBoundSubgraphSlot: collapse double-loop into one pass
- promotedWidgetView.set label: lift asymmetry rationale to JSDoc
- widgetValueStore.getOrRegister: drop unsound <TValue> cast; document
  register-wins semantics
- legacyProxyWidgetNormalization: document regex collision trade-off
- useProcessedWidgets test: align STORE_NAME with real makeCompositeKey shape

Amp-Thread-ID: https://ampcode.com/threads/T-019df4ad-2bed-75ad-91b2-4ef5886b48fa
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 13:51:19 -07:00
Alexander Brown
4b2ae3fd84 Merge branch 'main' into drjkl/subgraph-instance-store 2026-05-04 13:47:42 -07:00
Alexander Brown
6a33fd4a05 Merge branch 'main' into drjkl/subgraph-instance-store 2026-05-04 10:07:48 -07:00
DrJKL
e8272776aa fix(subgraph): address review follow-ups
Amp-Thread-ID: https://ampcode.com/threads/T-019df186-0508-73cc-a368-5e08fc65fea1
Co-authored-by: Amp <amp@ampcode.com>
2026-05-04 00:20:41 -07:00
Alexander Brown
0c0c3d9f2e Merge branch 'main' into drjkl/subgraph-instance-store 2026-05-03 21:16:55 -07:00
DrJKL
7dd1b7da4b fix(schema): trim unused promotion exports
Amp-Thread-ID: https://ampcode.com/threads/T-019df068-e630-74a6-99f3-85ade098bad8
Co-authored-by: Amp <amp@ampcode.com>
2026-05-03 19:14:16 -07:00
DrJKL
a9213760c8 test(subgraph): cover legacy 3-tuple save
Amp-Thread-ID: https://ampcode.com/threads/T-019df068-e630-74a6-99f3-85ade098bad8
Co-authored-by: Amp <amp@ampcode.com>
2026-05-03 19:10:57 -07:00
DrJKL
ae506465c7 fix(subgraph): serialize proxyWidgets as 2-tuples
Amp-Thread-ID: https://ampcode.com/threads/T-019de7d5-b234-758d-9af5-04d5759c2a43
Co-authored-by: Amp <amp@ampcode.com>
2026-05-03 17:33:57 -07:00
DrJKL
c27921f172 refactor(subgraph): extract makeCompositeKey utility for opaque tuple keys
Three places in the codebase JSON.stringify arrays to build opaque
composite Map/Set keys (favoritedWidgetsStore, SubgraphNode promotion
view keys, promoted-widget storeName). Extract a shared helper.

Reverts storeName from NUL-delimited back to JSON.stringify(array)
for consistency with the established codebase pattern. Still computed
once in the constructor.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:59:24 -07:00
DrJKL
7f80f72a62 Revert "fix(subgraph): drop unreachable typeof guards in resolveNodeWidget"
This reverts commit d6c9565622.
2026-05-01 23:51:36 -07:00
DrJKL
a0470f5116 test(subgraph): cover multi-instance promoted-widget selection round-trip
Two SubgraphNode instances of the same definition share the same
promoted-widget storeName but distinct host ids. Pin the contract
that loadSelections preserves both as distinct entries through
pruneLinearData + normalizeSelectedInput.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:47:29 -07:00
DrJKL
519191b5f6 refactor(subgraph): keep SubgraphNode.serialize() pure
The promotion-store is the runtime source of truth after the recent
fix; mutating this.properties.proxyWidgets in serialize() leaves a
stale mirror nothing reads. Write the snapshot to the serialized
output instead. Drop the dead [...existing] spread on widgets_values
since SubgraphNode never sets serialize_widgets, so super.serialize()
never produces it.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:42:49 -07:00
DrJKL
dbabf2de3c perf(subgraph): memoize promoted-widget storeName in constructor
storeName was JSON.stringify'd on every read; it's read inside the
Vue render cycle. The fields composing it are readonly. Compute
once in the constructor with a NUL separator that cannot legally
appear in widget names and is visibly not JSON.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:37:59 -07:00
DrJKL
d6c9565622 fix(subgraph): drop unreachable typeof guards in resolveNodeWidget
The typeof !== 'function' checks added in the prior promoted-widget
fix mask non-existent failure modes — every caller passes a real
LGraph/LGraphNode. Keep only the legitimate null-check that pairs
with the widened nullable graph parameter.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:35:03 -07:00
DrJKL
d1fb94dc7d fix(litegraph): dispatch trigger() events on graph.events bus
LGraph.trigger() now dispatches typed events on the existing
graph.events CustomEventTarget in addition to calling the legacy
onTrigger field, so consumers can subscribe without monkey-patching.

Migrate AppModeWidgetList to useEventListener on graph.events for
node:slot-label:changed, eliminating its rootGraph.onTrigger chain
that was vulnerable to non-LIFO disposal corruption.

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:31:10 -07:00
DrJKL
71cb98f7b3 fix(subgraph): defer widgets_values replay until SubgraphNode is attached
LGraphNode.clone() invokes configure() before the cloned node has a real
id. The setter on PromotedWidgetView guards id === -1 (to avoid orphan
cells in widgetValueStore), which silently dropped the replayed values.
Stash widgets_values during configure() if id === -1; flush on onAdded().

Amp-Thread-ID: https://ampcode.com/threads/T-019de73b-91ac-76c8-8b10-99552857d285
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 23:24:11 -07:00
DrJKL
559bc10b94 fix: subgraph promoted-widget rendering, value lookup, and rename
Fixes three deterministic Playwright failures introduced by the
subgraph-instance-store identity changes:

- `useProcessedWidgets.getWidgetIdentity` no longer falls back to the
  host nodeId for ordinary widgets. Stable identity is reserved for
  promoted views (which carry their own `widget.nodeId`); ordinary
  widgets fall through to the transient `index`-based renderKey.
  Fixes Vue Widget Reactivity "Should display added widgets" where
  duplicate widget refs were being collapsed by the dedupe loop.

- `computeProcessedWidgets` now falls back to the interior cell
  `(graphId, source.sourceNodeId, source.sourceWidgetName)` when the
  per-instance host cell is absent. Reads only — the per-instance
  write path is unchanged. Fixes the nested-subgraph promoted-widget
  values test where freshly-created promoted widgets rendered blank
  before pack.

- `AppModeWidgetList` chains `rootGraph.onTrigger` to bump a
  `labelVersion` ref on `node:slot-label:changed`, so `mappedSelections`
  re-evaluates after a widget rename. The chained handler preserves
  `useGraphNodeManager`'s existing handler chain and is restored on
  scope dispose.

- `PromotedWidgetView.label` setter now materializes the per-instance
  state cell when no subgraph slot is bound, instead of silently
  no-opping. The getter already falls back to `state?.label` when no
  slot is found, so renames on selected (non-IO) promoted widgets are
  now visible end-to-end. Fixes all four App-mode-widget-rename tests.

Includes regression tests for the dedupe and promoted-value paths in
`useProcessedWidgets.test.ts`, and updates the
`PromotedWidgetView` label-setter test to lock in the new invariant.

Amp-Thread-ID: https://ampcode.com/threads/T-019de643-67dc-77fe-ac05-2effc45a5b15
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 22:40:26 -07:00
DrJKL
07c3b324ca fix(subgraph): isolate promoted widget state
Amp-Thread-ID: https://ampcode.com/threads/T-019de5de-4b7c-708f-8bb8-10cd91287379
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 17:25:17 -07:00
DrJKL
2ab95698cf fix(subgraph): restore promoted widget instance state
Amp-Thread-ID: https://ampcode.com/threads/T-019de596-2219-76a4-96fc-0350ab5a83cc
Co-authored-by: Amp <amp@ampcode.com>
2026-05-01 15:58:23 -07:00
38 changed files with 3110 additions and 382 deletions

View File

@@ -0,0 +1,178 @@
{
"id": "6f7b11c1-410b-4754-8703-eb1bcc9aaf83",
"revision": 0,
"last_node_id": 4,
"last_link_id": 3,
"nodes": [
{
"id": 3,
"type": "PreviewAny",
"pos": [552.8850000000002, 657.0019999999998],
"size": [225, 176],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "source",
"type": "*",
"link": 3
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PreviewAny"
},
"widgets_values": [null, null, false]
},
{
"id": 4,
"type": "cac0d4c9-c8a8-497b-aed7-ae10c4bbd11f",
"pos": [270.74314453125, 662],
"size": [225, 168],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [3]
}
],
"properties": {
"proxyWidgets": [["2", "value"]]
},
"widgets_values": ["exterior"]
}
],
"links": [[3, 4, 0, 3, 0, "STRING"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "cac0d4c9-c8a8-497b-aed7-ae10c4bbd11f",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 3,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [-183.5137109375, 636, 128, 48]
},
"outputNode": {
"id": -20,
"bounding": [737, 626, 128, 68]
},
"inputs": [],
"outputs": [
{
"id": "510b65b6-6cd7-4787-aa36-845c2f702b53",
"name": "STRING",
"type": "STRING",
"linkIds": [1],
"localized_name": "STRING",
"pos": [761, 650]
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveStringMultiline",
"pos": [277, 575],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": [1]
}
],
"properties": {
"Node name for S&R": "PrimitiveStringMultiline"
},
"widgets_values": ["interior"]
},
{
"id": 2,
"type": "PrimitiveNode",
"pos": [4.486289062499992, 584.655],
"size": [225, 140],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": ["interior"]
}
],
"groups": [],
"links": [
{
"id": 2,
"origin_id": 2,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 1,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "STRING"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.15"
},
"version": 0.4
}

View File

@@ -0,0 +1,284 @@
{
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
"revision": 0,
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [120, 180],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Alpha\n"]
},
{
"id": 12,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [420, 180],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Beta\n"]
},
{
"id": 13,
"type": "422723e8-4bf6-438c-823f-881ca81acead",
"pos": [720, 180],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": null },
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [],
"properties": {},
"widgets_values": ["Gamma\n"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "422723e8-4bf6-438c-823f-881ca81acead",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
},
"outputNode": {
"id": -20,
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
},
"inputs": [
{
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
"name": "text",
"type": "STRING",
"linkIds": [10],
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
},
{
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
"name": "clip",
"type": "CLIP",
"linkIds": [11],
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
},
{
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
"name": "model",
"type": "MODEL",
"linkIds": [12],
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
},
{
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [13],
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
},
{
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [14],
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
},
{
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
"name": "latent_image",
"type": "LATENT",
"linkIds": [15],
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "CLIPTextEncode",
"pos": [661.59912109375, 314.13336181640625],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": 11
},
{
"localized_name": "text",
"name": "text",
"type": "STRING",
"widget": { "name": "text" },
"link": 10
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [""]
},
{
"id": 11,
"type": "KSampler",
"pos": [674.1234741210938, 570.5839233398438],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 12
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 13
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 14
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 10,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 1,
"type": "STRING"
},
{
"id": 11,
"origin_id": -10,
"origin_slot": 1,
"target_id": 10,
"target_slot": 0,
"type": "CLIP"
},
{
"id": 12,
"origin_id": -10,
"origin_slot": 2,
"target_id": 11,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 13,
"origin_id": -10,
"origin_slot": 3,
"target_id": 11,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 14,
"origin_id": -10,
"origin_slot": 4,
"target_id": 11,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 5,
"target_id": 11,
"target_slot": 3,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -7,6 +7,10 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ProxyWidgetTuple,
SerializedProxyWidgetTuple
} from '@/core/schemas/promotionSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
@@ -386,7 +390,7 @@ export class SubgraphHelper {
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
{ hostNodeId: string; promotedWidgets: SerializedProxyWidgetTuple[] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
@@ -401,15 +405,18 @@ export class SubgraphHelper {
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
(entry): entry is ProxyWidgetTuple =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
([sourceNodeId, serializedSourceWidgetName]) =>
[
sourceNodeId,
serializedSourceWidgetName
] satisfies SerializedProxyWidgetTuple
)
return {

View File

@@ -0,0 +1,134 @@
import { expect } from '@playwright/test'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const SPARSE_OVERRIDE_WORKFLOW =
'subgraphs/promoted-primitive-node-sparse-override'
const HOST_NODE_ID = '4'
const PROMOTED_WIDGET_NAME = 'value'
const EXTERIOR_VALUE = 'exterior'
const INTERIOR_VALUE = 'interior'
function findPrimitiveStringMultilineValue(
apiPrompt: ComfyApiWorkflow
): unknown {
const entry = Object.values(apiPrompt).find(
(node) => node.class_type === 'PrimitiveStringMultiline'
)
return entry?.inputs.value
}
/**
* Regression test for PR #11811 — promoted-widget sparse-override fix
* (commit 66c89c8e5).
*
* Workflow shape:
* - SubgraphNode id=4, widgets_values=["exterior"],
* properties.proxyWidgets=[["2", "value"]]
* - Interior PrimitiveNode id=2 (lazy widget creation in onAfterGraphConfigured)
* widgets_values=["interior"]
* - Interior PrimitiveStringMultiline id=1 receives PrimitiveNode value
* - Root PreviewAny id=3 consumes the subgraph output
*
* The bug: PrimitiveNode's lazy widget creation in onAfterGraphConfigured
* re-applied widgets_values=["interior"] *after* the SubgraphNode applied its
* exterior widgets_values, clobbering the per-instance override. The fix
* defers a replay of promoted-view values from SubgraphNode.onAfterGraphConfigured
* so the exterior override wins the materialization race, and graphToPrompt's
* getExecutableWidgetValue walks the ancestor SubgraphNode chain to pick up
* the per-instance override during prompt build.
*/
test.describe(
'Promoted widget sparse-override (PrimitiveNode lazy widget)',
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted widget renders the exterior override after load, not the interior default', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const hostNode = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await expect(hostNode).toBeVisible()
const promotedTextbox = hostNode.getByRole('textbox', {
name: PROMOTED_WIDGET_NAME,
exact: true
})
await expect(promotedTextbox).toHaveCount(1)
await expect(promotedTextbox).toHaveValue(EXTERIOR_VALUE)
await expect(promotedTextbox).not.toHaveValue(INTERIOR_VALUE)
})
test('Prompt-build resolves the exterior override through the ancestor SubgraphNode chain', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const apiPrompt = await comfyPage.workflow.getExportedWorkflow({
api: true
})
expect(findPrimitiveStringMultilineValue(apiPrompt)).toBe(EXTERIOR_VALUE)
})
test('Editing the promoted widget writes through to the interior and is reflected in prompt-build', async ({
comfyPage
}) => {
const editedValue = 'edited'
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const hostNode = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
const promotedTextbox = hostNode.getByRole('textbox', {
name: PROMOTED_WIDGET_NAME,
exact: true
})
await expect(promotedTextbox).toHaveValue(EXTERIOR_VALUE)
await promotedTextbox.fill(editedValue)
await expect(promotedTextbox).toHaveValue(editedValue)
const apiPrompt = await comfyPage.workflow.getExportedWorkflow({
api: true
})
expect(findPrimitiveStringMultilineValue(apiPrompt)).toBe(editedValue)
})
test('Reloading the workflow without saving resets the promoted widget to the exterior override', async ({
comfyPage
}) => {
const editedValue = 'edited'
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const hostNode = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
const promotedTextbox = hostNode.getByRole('textbox', {
name: PROMOTED_WIDGET_NAME,
exact: true
})
await promotedTextbox.fill(editedValue)
await expect(promotedTextbox).toHaveValue(editedValue)
await comfyPage.workflow.loadWorkflow(SPARSE_OVERRIDE_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const reloadedHost = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
const reloadedTextbox = reloadedHost.getByRole('textbox', {
name: PROMOTED_WIDGET_NAME,
exact: true
})
await expect(reloadedTextbox).toHaveValue(EXTERIOR_VALUE)
})
}
)

View File

@@ -0,0 +1,40 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
test.describe(
'Multi-instance subgraph promoted widget rendering in Vue mode',
{ tag: ['@subgraph', '@vue-nodes', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Each subgraph instance renders its own promoted widget value, not the interior default', async ({
comfyPage
}) => {
const expectedByNodeId: Record<string, string> = {
'11': 'Alpha\n',
'12': 'Beta\n',
'13': 'Gamma\n'
}
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
await comfyPage.vueNodes.waitForNodes(3)
for (const [nodeId, expectedValue] of Object.entries(expectedByNodeId)) {
const subgraphNode = comfyPage.vueNodes.getNodeLocator(nodeId)
await expect(subgraphNode).toBeVisible()
const textarea = subgraphNode.getByRole('textbox', {
name: 'text',
exact: true
})
await expect(textarea).toHaveValue(expectedValue)
}
})
}
)

View File

@@ -14,6 +14,30 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const LEGACY_THREE_TUPLE_WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const MULTI_INSTANCE_WORKFLOW =
'subgraphs/subgraph-multi-instance-promoted-text-values'
async function getPromotedHostWidgetValues(
comfyPage: ComfyPage,
nodeIds: string[]
) {
return comfyPage.page.evaluate((ids) => {
const graph = window.app!.canvas.graph!
return ids.map((id) => {
const node = graph.getNodeById(id)
if (!node?.isSubgraphNode()) {
return { id, values: [] as unknown[] }
}
return {
id,
values: (node.widgets ?? []).map((widget) => widget.value)
}
})
}, nodeIds)
}
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -498,4 +522,63 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
}
)
test(
'Legacy 3-tuple proxyWidgets entries serialize back to 2-tuples after load',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(LEGACY_THREE_TUPLE_WORKFLOW)
const hostNode = comfyPage.vueNodes.getNodeLocator('4')
await expect(hostNode).toBeVisible()
const promotedTextbox = hostNode.getByRole('textbox', {
name: 'text',
exact: true
})
await expect(promotedTextbox).toHaveCount(1)
await expect(promotedTextbox).toHaveValue('22222222222')
await expect(hostNode.getByText('text', { exact: true })).toBeVisible()
const serializedProxyWidgets = await comfyPage.page.evaluate(() => {
const serialized = window.app!.graph!.serialize()
const hostNode = serialized.nodes.find((node) => node.id === 4)
const proxyWidgets = hostNode?.properties?.proxyWidgets
return Array.isArray(proxyWidgets) ? proxyWidgets : []
})
expect(serializedProxyWidgets).toEqual([['3', '3: 2: text']])
expect(
serializedProxyWidgets.every(
(entry) => Array.isArray(entry) && entry.length === 2
)
).toBe(true)
}
)
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
comfyPage
}) => {
const hostNodeIds = ['11', '12', '13']
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
await comfyPage.workflow.loadWorkflow(MULTI_INSTANCE_WORKFLOW)
const initialValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(initialValues.map(({ values }) => values[0])).toEqual(expectedValues)
await comfyPage.subgraph.serializeAndReload()
const reloadedValues = await getPromotedHostWidgetValues(
comfyPage,
hostNodeIds
)
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
expectedValues
)
})
})

View File

@@ -8,7 +8,6 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -28,7 +27,10 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import {
getSelectedWidgetIdentity,
resolveNodeWidget
} from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -157,10 +159,7 @@ 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 [storeId, storeName] = getSelectedWidgetIdentity(node, widget)
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, provide, ref, shallowRef } from 'vue'
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
import { useI18n } from 'vue-i18n'
@@ -64,8 +64,20 @@ useEventListener(
() => (graphNodes.value = app.rootGraph.nodes)
)
// Widget renames mutate `widget.label` on the live LiteGraph object and fire
// `node:slot-label:changed` on `graph.events`. That object is non-reactive,
// so the rendered label (line ~203) goes stale. Bump a version counter on the
// event and read it inside `mappedSelections` to force re-evaluation.
const labelVersion = ref(0)
useEventListener(
app.rootGraph.events,
'node:slot-label:changed',
() => labelVersion.value++
)
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
void labelVersion.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
@@ -85,17 +97,14 @@ const mappedSelections = computed((): WidgetEntry[] => {
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
vueWidget.instanceWidgetName === widget.storeName
)
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = String(node.id)
return [
{

View File

@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
@@ -187,7 +188,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
it('updates slotMetadata for promoted widgets where SafeWidgetData.displayName differs from input.widget.name', async () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
@@ -201,7 +202,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
// sets SafeWidgetData.displayName to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
@@ -224,7 +225,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// SafeWidgetData.displayName is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
@@ -471,10 +472,15 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
expect(mappedWidget?.name).toBe('picker')
expect(mappedWidget?.sourceNodeLocatorId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
expect(mappedWidget?.source).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'picker',
disambiguatingSourceNodeId: undefined
})
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
@@ -516,7 +522,7 @@ describe('Nested promoted widget mapping', () => {
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
new Set(promotedWidgets?.map((widget) => widget.sourceNodeLocatorId))
).toEqual(
new Set([
`${subgraph.id}:${linkedNode.id}`,
@@ -580,7 +586,7 @@ describe('Nested promoted widget mapping', () => {
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
new Set(promotedWidgets?.map((widget) => widget.sourceNodeLocatorId))
).toEqual(
new Set([
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
@@ -590,6 +596,98 @@ describe('Nested promoted widget mapping', () => {
})
})
describe('safeWidgetMapper per-instance widget identity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('populates nodeId with the SubgraphNode instance id and instanceWidgetName with the view storeName for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('CLIPTextEncode')
const interiorInput = interiorNode.addInput('text', 'STRING')
interiorNode.addWidget('text', 'text', '', () => undefined, {})
interiorInput.widget = { name: 'text' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const promotedView = subgraphNode.widgets[0]
if (!promotedView || !isPromotedWidgetView(promotedView)) {
throw new Error('Expected first widget to be a promoted view')
}
const expectedStoreName = promotedView.storeName
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(String(subgraphNode.id))
?.widgets?.find((w) => w.name === 'text')
expect(widgetData).toBeDefined()
expect(widgetData?.nodeId).toBe(String(subgraphNode.id))
expect(widgetData?.instanceWidgetName).toBe(expectedStoreName)
})
it('does not set nodeId or instanceWidgetName for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
graph.add(node)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetData = vueNodeData
.get(String(node.id))
?.widgets?.find((w) => w.name === 'steps')
expect(widgetData).toBeDefined()
expect(widgetData?.nodeId).toBeUndefined()
expect(widgetData?.instanceWidgetName).toBeUndefined()
})
it('produces distinct nodeId values for two SubgraphNode instances of one definition', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('CLIPTextEncode')
const interiorInput = interiorNode.addInput('text', 'STRING')
interiorNode.addWidget('text', 'text', '', () => undefined, {})
interiorInput.widget = { name: 'text' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const instanceA = createTestSubgraphNode(subgraph, { id: 100 })
instanceA._internalConfigureAfterSlots()
const graph = instanceA.graph as LGraph
graph.add(instanceA)
const instanceB = createTestSubgraphNode(subgraph, {
id: 200,
parentGraph: graph
})
instanceB._internalConfigureAfterSlots()
graph.add(instanceB)
const { vueNodeData } = useGraphNodeManager(graph)
const widgetA = vueNodeData
.get(String(instanceA.id))
?.widgets?.find((w) => w.name === 'text')
const widgetB = vueNodeData
.get(String(instanceB.id))
?.widgets?.find((w) => w.name === 'text')
expect(widgetA?.nodeId).toBe('100')
expect(widgetB?.nodeId).toBe('200')
// Both share the same definition so instanceWidgetName matches —
// only nodeId distinguishes them.
expect(widgetA?.instanceWidgetName).toBe(widgetB?.instanceWidgetName)
})
})
describe('Promoted widget sourceExecutionId', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))

View File

@@ -50,13 +50,19 @@ export interface WidgetSlotMetadata {
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
* Per-instance promoted-widget state still lives in widgetValueStore.
*/
export interface SafeWidgetData {
/** For promoted widgets: host SubgraphNode instance used for store lookup. */
nodeId?: NodeId
storeNodeId?: NodeId
/** Display-facing widget name; not a stable per-instance key. */
name: string
storeName?: string
/** Opaque widget-store name paired with nodeId in `widgetValueStore`—do not parse. */
instanceWidgetName?: string
/** Promoted source identity for grouping and lookups. */
source?: PromotedWidgetSource
/** Locator ID of the resolved source node. */
sourceNodeLocatorId?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
@@ -80,22 +86,13 @@ export interface SafeWidgetData {
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
/** Subgraph input slot name used when displayName comes from the source widget. */
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
* host subgraph node. Used for missing-model lookups that key by
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
/** Execution ID of the resolved source node for promoted-widget lookups. */
sourceExecutionId?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
/** Current promoted-view label, if any. */
promotedLabel?: string
}
@@ -277,7 +274,7 @@ function safeWidgetMapper(
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
// Get shared widget enhancements.
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
@@ -297,7 +294,7 @@ function safeWidgetMapper(
const isPromotedPseudoWidget =
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
// Extract render-facing widget options.
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
@@ -319,25 +316,47 @@ function safeWidgetMapper(
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const storeName = isPromotedWidgetView(widget)
// Host node id: for promoted views, the SubgraphNode instance id;
// for non-promoted widgets, undefined (consumers fall back to the
// host node id from rendering context).
const nodeId = isPromotedWidgetView(widget) ? String(node.id) : undefined
const sourceWidgetName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = storeName ?? displayName
const rawSourceLocalId = isPromotedWidgetView(widget)
? (sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId)
: undefined
const sourceLocalId =
rawSourceLocalId != null ? String(rawSourceLocalId) : undefined
const source: PromotedWidgetSource | undefined =
isPromotedWidgetView(widget) && sourceLocalId && sourceWidgetName
? {
sourceNodeId: sourceLocalId,
sourceWidgetName,
disambiguatingSourceNodeId:
promotedSource?.disambiguatingSourceNodeId
}
: undefined
const sourceNodeLocatorId =
source && subgraphId
? `${subgraphId}:${source.sourceNodeId}`
: undefined
const instanceWidgetName = isPromotedWidgetView(widget)
? widget.storeName
: undefined
const widgetDisplayName = sourceWidgetName ?? displayName
return {
nodeId,
storeNodeId: nodeId,
name,
storeName,
name: widgetDisplayName,
instanceWidgetName,
source,
sourceNodeLocatorId,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
@@ -350,9 +369,10 @@ function safeWidgetMapper(
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
// For promoted widgets, displayName is sourceWidgetName while
// widget.name is the subgraph input slot name — store the slot
// name for lookups.
slotName: widgetDisplayName !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)

View File

@@ -2,6 +2,8 @@ import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetT
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
// Collision: a widget literally named `"<digits>: rest"` is ambiguous;
// `normalizeLegacyProxyWidgetEntry` resolves the literal name first.
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>

View File

@@ -24,6 +24,12 @@ export interface PromotedWidgetView extends IBaseWidget {
* origin.
*/
readonly disambiguatingSourceNodeId?: string
/**
* Opaque widget-store key paired with the host SubgraphNode. Built via
* `makeCompositeKey` over `(sourceNodeId, sourceWidgetName,
* disambiguatingSourceNodeId ?? '')`. Treat as opaque: do not parse.
*/
readonly storeName: string
}
export function isPromotedWidgetView(

View File

@@ -0,0 +1,113 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
// Barrel import for SubgraphNode/LGraph circular dep
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
widgetStates: new Map(),
setPositionOverride: vi.fn(),
clearPositionOverride: vi.fn()
})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
/**
* Mimics PrimitiveNode (src/extensions/core/widgetInputs.ts:32+) — empty
* widgets until onAfterGraphConfigured creates them and re-applies
* widgets_values. Reproduces the load-time race against
* SubgraphNode._replayPromotedWidgetValues.
*/
class LazyPrimitiveLikeNode extends LGraphNode {
constructor() {
super('LazyPrimitiveLike')
this.serialize_widgets = true
}
override onAfterGraphConfigured(): void {
if (this.widgets?.length) return
const widget = this.addWidget('text', 'value', '', () => {})
const stored = this.widgets_values
if (stored?.length) {
widget.value = stored[0] as string
}
}
}
describe('PromotedWidgetView with lazy-creation interior widget', () => {
test('per-instance "exterior" override survives interior lazy widget materialization', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const interior = new LazyPrimitiveLikeNode()
interior.widgets_values = ['interior']
subgraph.add(interior)
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph
})
rootGraph.add(subgraphNode)
// Drive the load path: configure with proxyWidgets and exterior value.
subgraphNode.configure({
id: subgraphNode.id,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: {
proxyWidgets: [[String(interior.id), 'value']]
},
widgets_values: ['exterior']
})
// _replayPromotedWidgetValues has run, but interior.widgets is empty —
// write-through no-ops; only the per-instance override holds "exterior".
expect(interior.widgets?.length ?? 0).toBe(0)
// Lazy materialization clobbers any prior interior write with the
// serialized widgets_values=["interior"].
interior.onAfterGraphConfigured()
expect(interior.widgets?.[0].value).toBe('interior')
// SubgraphNode.onAfterGraphConfigured (called child-first by
// triggerCallbackOnAllNodes in production) re-projects the per-instance
// override onto the now-materialized interior widget.
subgraphNode.onAfterGraphConfigured?.()
const widgetStore = useWidgetValueStore()
const view = subgraphNode.widgets[0]
expect(view.value).toBe('exterior')
expect(interior.widgets?.[0].value).toBe('exterior')
const interiorCell = widgetStore.getWidget(
rootGraph.id,
interior.id,
'value'
)
expect(interiorCell?.value).toBe('exterior')
})
})

View File

@@ -21,11 +21,9 @@ import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetVi
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { usePromotionStore } from '@/stores/promotionStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestRootGraph,
@@ -75,7 +73,7 @@ function setupSubgraph(
function setPromotions(
subgraphNode: SubgraphNode,
entries: [string, string][]
entries: SerializedProxyWidgetTuple[]
) {
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
@@ -263,7 +261,7 @@ describe(createPromotedWidgetView, () => {
expect(view.linkedWidgets?.[0].name).toBe('control_after_generate')
})
test('value is store-backed via widgetValueStore', () => {
test('value writes propagate to the interior widget (write-through)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'initial', () => {})
@@ -273,18 +271,32 @@ describe(createPromotedWidgetView, () => {
'myWidget'
)
// Value should read from the store (which was populated by addWidget)
// Value should read from interior default initially
expect(view.value).toBe('initial')
// Setting value through the view updates the store
view.value = 'updated'
expect(view.value).toBe('updated')
// The interior widget reads from the same store
expect(view.value).toBe('updated')
// Write-through projects the new value onto the interior widget so
// direct interior consumers (prompt-build, etc.) stay consistent.
expect(innerNode.widgets![0].value).toBe('updated')
const perInstanceCell = useWidgetValueStore().getWidget(
subgraphNode.rootGraph.id,
subgraphNode.id,
view.storeName
)
expect(perInstanceCell?.value).toBe('updated')
const interiorCell = useWidgetValueStore().getWidget(
subgraphNode.rootGraph.id,
innerNode.id,
'myWidget'
)
expect(interiorCell?.value).toBe('updated')
})
test('value falls back to interior widget when store entry is missing', () => {
test('value falls back to interior widget when no store entry exists', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const fallbackWidgetShape = {
@@ -305,12 +317,11 @@ describe(createPromotedWidgetView, () => {
'myWidget'
)
// Read falls back to the interior widget when no store entry exists
expect(view.value).toBe('initial')
view.value = 'updated'
expect(fallbackWidget.value).toBe('updated')
})
test('value setter falls back to host widget when linked states are unavailable', () => {
test('value setter writes to both per-instance override and interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
@@ -327,11 +338,11 @@ describe(createPromotedWidgetView, () => {
const linkedView = promotedWidgets(subgraphNode)[0]
if (!linkedView) throw new Error('Expected a linked promoted widget')
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
linkedView.value = 'updated'
expect(linkedView.value).toBe('updated')
// Write-through projects onto the interior so direct interior
// consumers (prompt-build, legacy serialization) stay consistent.
expect(linkedNode.widgets?.[0].value).toBe('updated')
})
@@ -475,17 +486,80 @@ describe(createPromotedWidgetView, () => {
expect(view.hidden).toBe(true)
})
test('label setter persists to widget state', () => {
test('label setter propagates to the bound subgraph slot', () => {
// Real promotion fixture: produce a view with a genuine slot binding
// (input._widget === view AND input._subgraphSlot set), exercising the
// production path rather than asserting the rejected widget-state
// implementation detail. Mirrors the pattern at "defers promotions
// while subgraph node id is -1 and flushes on add" earlier in this file.
const subgraph = createTestSubgraph({
inputs: [{ name: 'myWidget', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 41 })
subgraphNode.graph?.add(subgraphNode)
const innerNode = new LGraphNode('InnerNode')
const innerInput = innerNode.addInput('myWidget', '*')
innerNode.addWidget('text', 'myWidget', 'val', () => {})
innerInput.widget = { name: 'myWidget' }
subgraph.add(innerNode)
subgraph.inputNode.slots[0].connect(innerInput, innerNode)
subgraphNode._internalConfigureAfterSlots()
const view = subgraphNode.widgets[0] as PromotedWidgetView | undefined
if (!view) throw new Error('Expected a promoted view')
view.label = 'Renamed'
expect(view.label).toBe('Renamed')
// Slot-side persistence is the durable home for label state.
const slot = subgraphNode.inputs.find(
(i) => i._widget === view
)?._subgraphSlot
expect(slot?.label).toBe('Renamed')
})
test('label binding uses the exact promoted view instance when same source widget is promoted twice', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const view = createPromotedWidgetView(
innerNode.addWidget('text', 'shared', 'value', () => {})
subgraphNode.addInput('slot_a', '*')
subgraphNode.addInput('slot_b', '*')
const viewA = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'myWidget'
'shared',
'Slot A'
)
view.label = 'Renamed'
expect(view.label).toBe('Renamed')
const viewB = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'shared',
'Slot B'
)
if (!subgraphNode.inputs[0] || !subgraphNode.inputs[1]) {
throw new Error('Expected two subgraph inputs')
}
subgraphNode.inputs[0]._widget = viewA
Object.defineProperty(subgraphNode.inputs[0], '_subgraphSlot', {
value: { name: 'slot_a', label: 'A' },
configurable: true,
writable: true
})
subgraphNode.inputs[1]._widget = viewB
Object.defineProperty(subgraphNode.inputs[1], '_subgraphSlot', {
value: { name: 'slot_b', label: 'B' },
configurable: true,
writable: true
})
expect(viewA.label).toBe('A')
expect(viewB.label).toBe('B')
})
test('value getter handles number values via isWidgetValue', () => {
@@ -515,16 +589,7 @@ describe(createPromotedWidgetView, () => {
test('value setter handles object values via isWidgetValue', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
const fallbackWidget = {
name: 'objWidget',
type: 'text',
value: 'old',
options: {}
} as unknown as IBaseWidget
innerNode.widgets = [fallbackWidget]
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
innerNode.addWidget('text', 'objWidget', 'old', () => {})
const view = createPromotedWidgetView(
subgraphNode,
@@ -534,7 +599,9 @@ describe(createPromotedWidgetView, () => {
const objValue = { key: 'data' }
view.value = objValue
expect(fallbackWidget.value).toBe(objValue)
expect(view.value).toEqual(objValue)
// Write-through projects the object onto the interior widget.
expect(innerNode.widgets![0].value).toEqual(objValue)
})
test('onPointerDown returns true when interior widget onPointerDown handles it', () => {
@@ -876,16 +943,19 @@ describe('SubgraphNode.widgets getter', () => {
linkedView.value = 'shared-value'
// Both linked nodes share the same SubgraphInput slot, so the value
// propagates to all connected widgets via getLinkedInputWidgets().
// Write-through hits only the representative interior (first link).
// Sibling interiors and unrelated promoted peers stay independent.
expect(linkedView.value).toBe('shared-value')
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
promotedView.value = 'independent-updated'
expect(promotedView.value).toBe('independent-updated')
expect(linkedView.value).toBe('shared-value')
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('b')
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
})
@@ -1223,11 +1293,17 @@ describe('SubgraphNode.widgets getter', () => {
firstView.value = 'first-updated'
secondView.value = 'second-updated'
// Distinct subgraph slots map to distinct interior nodes, so
// write-through stays scoped to each view's own representative.
expect(firstView.value).toBe('first-updated')
expect(secondView.value).toBe('second-updated')
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
subgraphNode.serialize()
expect(firstView.value).toBe('first-updated')
expect(secondView.value).toBe('second-updated')
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
})
@@ -1548,14 +1624,22 @@ describe('SubgraphNode.widgets getter', () => {
independentView.value = 'independent-value'
linkedView.value = 'shared-linked'
const widgetStore = useWidgetValueStore()
const getValue = (nodeId: string) =>
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
?.value
// Per-instance views carry their own values; both linked and
// independent promoted views read from their per-instance store entries
// and do not contaminate each other.
expect(linkedView.value).toBe('shared-linked')
expect(independentView.value).toBe('independent-value')
expect(getValue('20')).toBe('shared-linked')
expect(getValue('18')).toBe('shared-linked')
expect(getValue('19')).toBe('independent-value')
// Per-instance overrides are owned by the SubgraphNode itself at
// `(graphId, hostNode.id, *)`. Each PromotedWidgetView is a first-class
// widget on the SubgraphNode, so writes land in the natural
// (graphId, nodeId, *) namespace and are isolated from interior entries.
const overrideValues = useWidgetValueStore()
.getNodeWidgets(graph.id, hostNode.id)
.map((entry) => entry.value)
expect(overrideValues).toEqual(
expect.arrayContaining(['shared-linked', 'independent-value'])
)
})
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
@@ -2022,13 +2106,15 @@ describe('three-level nested value propagation', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('value set at outermost level propagates to concrete widget', () => {
test('value set at outermost level is visible through the promoted view', () => {
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].value).toBe(100)
subgraphNodeA.widgets[0].value = 200
expect(subgraphNodeA.widgets[0].value).toBe(200)
// Write-through chains through every nested view down to the concrete.
expect(concreteNode.widgets![0].value).toBe(200)
})
@@ -2108,6 +2194,8 @@ describe('three-level nested value propagation', () => {
widgets[1].value = 'updated-second'
// Write-through follows the disambig chain — only secondTextNode's
// interior is mutated; firstTextNode stays untouched.
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
expect(widgets[0].value).toBe('11111111111')
@@ -2156,13 +2244,14 @@ describe('multi-link representative determinism for input-based promotion', () =
// Read returns the first link's value
expect(widgets[0].value).toBe('first-val')
// Write propagates to all linked nodes
// Write-through hits only the representative interior (first link).
// Sibling interiors connected via the same input stay at their defaults.
widgets[0].value = 'updated'
expect(firstNode.widgets![0].value).toBe('updated')
expect(secondNode.widgets![0].value).toBe('updated')
expect(thirdNode.widgets![0].value).toBe('updated')
expect(secondNode.widgets![0].value).toBe('second-val')
expect(thirdNode.widgets![0].value).toBe('third-val')
// Repeated reads are still deterministic
// Repeated reads through the view return the per-instance override
expect(widgets[0].value).toBe('updated')
})
})
@@ -2277,13 +2366,15 @@ describe('promoted combo rendering', () => {
expect(renderedText).toContain('a')
})
test('value updates propagate through two promoted input layers', () => {
test('value updates at the outer layer write through to the interior combo widget', () => {
const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph()
comboWidget.computedDisabled = true
const promotedWidget = subgraphNodeB.widgets[0]
expect(promotedWidget.value).toBe('a')
promotedWidget.value = 'b'
expect(promotedWidget.value).toBe('b')
// Write-through chains down to the concrete combo widget.
expect(comboWidget.value).toBe('b')
const fillText = vi.fn()
@@ -2677,4 +2768,103 @@ describe('DOM widget promotion', () => {
'dom-widget-widgetB'
)
})
test('value setter is a no-op while the SubgraphNode is unattached (id === -1)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'preAttach', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'preAttach'
)
// Detach the SubgraphNode so its id reverts to the pre-attach sentinel.
Object.assign(subgraphNode, { id: -1 })
view.value = 'should-not-persist'
// No WidgetState entry registered at id -1
const entries = useWidgetValueStore().getNodeWidgets(
subgraphNode.rootGraph.id,
-1
)
expect(entries).toHaveLength(0)
// Read still falls back to the interior default
expect(view.value).toBe('initial')
})
test('label setter is a no-op while the SubgraphNode is unattached (id === -1)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'preAttachLabel', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'preAttachLabel'
)
Object.assign(subgraphNode, { id: -1 })
view.label = 'My Label'
const entries = useWidgetValueStore().getNodeWidgets(
subgraphNode.rootGraph.id,
-1
)
expect(entries).toHaveLength(0)
})
test('label setter materializes a per-instance override when no slot is bound', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'labelOnly', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'labelOnly'
)
view.label = 'My Label'
// Without a bound subgraph slot the per-instance override is the only
// place the new label can live; the setter must materialize it so
// the rename actually takes effect (the getter falls back to
// state?.label when no slot is found).
const entries = useWidgetValueStore().getNodeWidgets(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(entries).toHaveLength(1)
expect(entries[0].label).toBe('My Label')
expect(view.label).toBe('My Label')
})
test('label setter updates an existing per-instance override when a value override is present', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'valueAndLabel', 'initial', () => {})
const view = createPromotedWidgetView(
subgraphNode,
String(innerNode.id),
'valueAndLabel'
)
// Triggering a value write materialises the override (legitimate ownership).
view.value = 'override'
// Now setting a label should update the existing override, not create a new one.
view.label = 'My Label'
const entries = useWidgetValueStore().getNodeWidgets(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(entries).toHaveLength(1)
expect(entries[0].label).toBe('My Label')
})
})

View File

@@ -1,4 +1,4 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
@@ -9,17 +9,13 @@ import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import { makeCompositeKey } from '@/utils/compositeKey'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -74,6 +70,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
readonly sourceNodeId: string
readonly sourceWidgetName: string
/** Opaque widget-store name paired with the host SubgraphNode; do not parse. */
readonly storeName: string
readonly serialize = false
@@ -91,7 +89,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
/** Lazily cached bound subgraph slot reference. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
@@ -105,6 +103,11 @@ class PromotedWidgetView implements IPromotedWidgetView {
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.storeName = makeCompositeKey([
nodeId,
widgetName,
disambiguatingSourceNodeId ?? ''
])
this.graphId = subgraphNode.rootGraph.id
}
@@ -150,71 +153,56 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get value(): IBaseWidget['value'] {
// Sparse override: a WidgetState entry exists only when explicitly set;
// otherwise read through to the live interior widget.
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
let didUpdateState = false
for (const linkedWidget of linkedWidgets) {
const state = widgetStore.getWidget(
this.graphId,
linkedWidget.nodeId,
linkedWidget.widgetName
)
if (state) {
state.value = value
didUpdateState = true
}
}
if (!isWidgetValue(value)) return
// Pre-attach sentinel: skip writes before LGraph.add() assigns the real id.
if (this.subgraphNode.id === -1) return
const resolved = this.resolveDeepest()
if (resolved) {
const resolvedState = widgetStore.getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (resolvedState) {
resolvedState.value = value
didUpdateState = true
}
}
// The per-instance override keeps Vue render and canvas draw fast paths correct.
this.ensureInstanceState().value = value
if (didUpdateState) return
}
const state = this.getWidgetState()
if (state) {
state.value = value
return
}
const resolved = this.resolveAtHost()
if (resolved && isWidgetValue(value)) {
resolved.widget.value = value
// Write-through to the interior widget: prompt-build, legacy
// serialization, and nested promoted views all read the interior widget
// directly. Without this projection they would observe the stale
// workflow-restored default rather than the user-edited value.
const interior = this.resolveAtHost()?.widget
if (interior && interior.value !== value) {
interior.value = value
}
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName
}
/** Slot-bound: only update an existing override. Unbound: materialize one. */
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
// Pre-attach sentinel guard: skip per-instance override write before LGraph.add().
if (this.subgraphNode.id === -1) return
if (slot) {
const existing = this.getWidgetState()
if (existing) existing.label = value
} else {
this.ensureInstanceState().label = value
}
}
serializeValue(_node: LGraphNode, _index: number): IBaseWidget['value'] {
return this.value
}
/**
@@ -223,7 +211,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
@@ -236,21 +224,28 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
// Identity match wins; otherwise fall back to source-identity match
// (sibling view bound to the same promoted source).
let sourceMatch: SubgraphSlotRef | undefined
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
if (input._widget === this) return slot
if (sourceMatch) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
w.sourceWidgetName === this.sourceWidgetName &&
w.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
) {
return slot
sourceMatch = slot
}
}
return undefined
return sourceMatch
}
get hidden(): boolean {
@@ -385,70 +380,27 @@ class PromotedWidgetView implements IPromotedWidgetView {
return resolved
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
private getWidgetState(): WidgetState | undefined {
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
this.subgraphNode.id,
this.storeName
)
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
const boundWidget = input._widget
if (boundWidget === this) return true
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName &&
boundWidget.disambiguatingSourceNodeId ===
this.disambiguatingSourceNodeId
)
}
return input._subgraphSlot
.getConnectedWidgets()
.filter(hasWidgetNode)
.some(
(widget) =>
String(widget.node.id) === this.sourceNodeId &&
widget.name === this.sourceWidgetName
)
/** Lazily creates this view's per-instance state from source defaults. */
private ensureInstanceState(): WidgetState {
const seed = this.resolveDeepest()?.widget ?? this.resolveAtHost()?.widget
return useWidgetValueStore().getOrRegister(this.graphId, {
nodeId: this.subgraphNode.id,
name: this.storeName,
type: seed?.type ?? 'text',
value: seed?.value,
options: seed?.options ?? {},
label: seed?.label,
serialize: seed?.serialize,
disabled: seed?.disabled
})
const linkedInput = linkedInputSlot?._subgraphSlot
if (!linkedInput) return []
return linkedInput
.getConnectedWidgets()
.filter(hasWidgetNode)
.map((widget) => ({
nodeId: stripGraphPrefix(String(widget.node.id)),
widgetName: widget.name,
widget
}))
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.filter((state): state is WidgetState => state !== undefined)
}
private getProjectedWidget(resolved: {

View File

@@ -103,7 +103,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
test('Promoted view falls back to interior; promoted writes mutate interior (write-through)', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -113,9 +113,15 @@ describe('Subgraph proxyWidgets', () => {
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
// Sparse override: interior writes are visible until the view
// acquires its own override.
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
// Promoted writes record an override AND write through to the interior.
subgraphNode.widgets[0].value = 'test2'
expect(subgraphNode.widgets[0].value).toBe('test2')
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
@@ -253,7 +259,7 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize does not produce widgets_values for promoted views', () => {
test('serialize stores widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
@@ -265,9 +271,7 @@ describe('Subgraph proxyWidgets', () => {
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()
expect(serialized.widgets_values).toEqual(['value'])
})
test('serialize preserves proxyWidgets in properties', () => {
@@ -291,6 +295,28 @@ describe('Subgraph proxyWidgets', () => {
])
})
test('serialize() does not mutate the live properties.proxyWidgets', () => {
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 before = subgraphNode.properties.proxyWidgets
const beforeSnapshot = JSON.parse(JSON.stringify(before))
subgraphNode.serialize()
expect(subgraphNode.properties.proxyWidgets).toBe(before)
expect(subgraphNode.properties.proxyWidgets).toStrictEqual(beforeSnapshot)
})
test('multi-link representative is deterministic across repeated reads', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared_input', type: '*' }]
@@ -371,8 +397,10 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNodeA.widgets[0].type).toBe('number')
expect(subgraphNodeA.widgets[0].value).toBe(42)
// Setting value at outermost level propagates to concrete widget
// Outermost write records an override AND writes through every nested
// promoted view down to the concrete interior widget.
subgraphNodeA.widgets[0].value = 99
expect(subgraphNodeA.widgets[0].value).toBe(99)
expect(concreteNode.widgets![0].value).toBe(99)
})

View File

@@ -3,10 +3,26 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetTupleSchema = z.union([
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
export const serializedProxyWidgetTupleSchema = z.tuple([
z.string(),
z.string()
])
export type SerializedProxyWidgetTuple = z.infer<
typeof serializedProxyWidgetTupleSchema
>
const legacyProxyWidgetTupleSchema = z.tuple([
z.string(),
z.string(),
z.string()
])
export const proxyWidgetTupleSchema = z.union([
legacyProxyWidgetTupleSchema,
serializedProxyWidgetTupleSchema
])
export type ProxyWidgetTuple = z.infer<typeof proxyWidgetTupleSchema>
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>

View File

@@ -1027,4 +1027,29 @@ describe('Zero UUID handling in configure', () => {
subgraph.configure(subgraphData)
expect(subgraph.id).toBe(zeroUuid)
})
describe('trigger() events bus', () => {
it('dispatches node:slot-label:changed on graph.events when trigger() is called', () => {
const graph = new LGraph()
const received: Array<{ type: string; nodeId: NodeId }> = []
graph.events.addEventListener('node:slot-label:changed', (e) => {
received.push(e.detail)
})
graph.trigger('node:slot-label:changed', { nodeId: 42 })
expect(received.length).toBe(1)
expect(received[0]).toEqual({
type: 'node:slot-label:changed',
nodeId: 42
})
})
it('still invokes the legacy onTrigger field for backward compatibility', () => {
const graph = new LGraph()
const received: Array<{ type: string }> = []
graph.onTrigger = (event) => received.push(event)
graph.trigger('node:slot-label:changed', { nodeId: 7 })
expect(received.length).toBe(1)
expect(received[0].type).toBe('node:slot-label:changed')
})
})
})

View File

@@ -1367,7 +1367,9 @@ export class LGraph
])
if (validEventTypes.has(action) && param && typeof param === 'object') {
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
const event = { type: action, ...param } as LGraphTriggerEvent
this.onTrigger?.(event)
this.events.dispatch(action as LGraphTriggerEvent['type'], event)
}
// Don't handle unknown events - just ignore them
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { LGraph, LGraphNode, createUuidv4 } from '@/lib/litegraph/src/litegraph'
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
import type {
@@ -11,7 +12,7 @@ import type {
function createSerialisedNode(
id: number,
type: string,
proxyWidgets?: Array<[string, string]>
proxyWidgets?: SerializedProxyWidgetTuple[]
): ISerialisedNode {
return {
id,

View File

@@ -2,6 +2,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { LGraphTriggerEvent } from '@/lib/litegraph/src/types/graphTriggers'
import type {
ExportedSubgraph,
ISerialisedGraph,
@@ -9,6 +10,23 @@ import type {
} from '@/lib/litegraph/src/types/serialisation'
export interface LGraphEventMap {
'node:slot-label:changed': Extract<
LGraphTriggerEvent,
{ type: 'node:slot-label:changed' }
>
'node:slot-links:changed': Extract<
LGraphTriggerEvent,
{ type: 'node:slot-links:changed' }
>
'node:slot-errors:changed': Extract<
LGraphTriggerEvent,
{ type: 'node:slot-errors:changed' }
>
'node:property:changed': Extract<
LGraphTriggerEvent,
{ type: 'node:property:changed' }
>
configuring: {
/** The data that was used to configure the graph. */
data: ISerialisedGraph | SerialisableGraph

View File

@@ -0,0 +1,593 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
ExportedSubgraphInstance,
ISlotType,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
/**
* Registers a minimal SubgraphNode subclass for a subgraph definition so that
* `LiteGraph.createNode(subgraphId)` (which is invoked by `LGraphNode.clone`)
* succeeds 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)
}
function createNodeWithWidget(
title: string,
widgetValue: number = 42,
slotType: ISlotType = 'number'
) {
const node = new LGraphNode(title)
const input = node.addInput('value', slotType)
node.addOutput('out', slotType)
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget, input }
}
const registeredTypes: string[] = []
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
afterEach(() => {
for (const type of registeredTypes) {
LiteGraph.unregisterNodeType(type)
}
registeredTypes.length = 0
})
describe('SubgraphNode multi-instance widget isolation', () => {
it('preserves per-instance widget values after configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
// Simulate what LGraph.configure does: call configure with different widgets_values
instance1.configure({
id: 201,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [10]
})
instance2.configure({
id: 202,
type: subgraph.id,
pos: [400, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 1,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [20]
})
const widgets1 = instance1.widgets!
const widgets2 = instance2.widgets!
expect(widgets1.length).toBeGreaterThan(0)
expect(widgets2.length).toBeGreaterThan(0)
expect(widgets1[0].value).toBe(10)
expect(widgets2[0].value).toBe(20)
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
expect(instance1.serialize().widgets_values).toEqual([10])
expect(instance2.serialize().widgets_values).toEqual([20])
})
it('round-trips per-instance widget values through serialize and configure', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
originalInstance.configure({
id: 301,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
restoredInstance.configure({
...serialized,
id: 302,
type: subgraph.id
})
const restoredWidget = restoredInstance.widgets?.[0]
expect(restoredWidget?.value).toBe(33)
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
})
it('fresh sibling instances follow shared interior until they acquire explicit per-instance overrides', () => {
// Sparse override: untouched siblings share the live interior; once
// an instance is explicitly set or restored, it diverges.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 7)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
instance1.graph!.add(instance1)
instance2.graph!.add(instance2)
const widget1 = instance1.widgets?.[0]
const widget2 = instance2.widgets?.[0]
expect(widget1?.value).toBe(7)
expect(widget2?.value).toBe(7)
widget1!.value = 10
// Instance 2 has no override yet, so it reads through to the now-
// mutated shared interior.
expect(widget1?.value).toBe(10)
expect(widget2?.value).toBe(10)
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
expect(widget2?.serializeValue?.(instance2, 0)).toBe(10)
// Setting widget2 gives it its own override, allowing divergence.
widget2!.value = 33
expect(widget1?.value).toBe(10)
expect(widget2?.value).toBe(33)
})
it('keeps per-instance override sticky when the inner source widget changes directly', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
originalInstance.configure({
id: 601,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
const serialized = originalInstance.serialize()
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
restoredInstance.configure({
...serialized,
id: 602,
type: subgraph.id
})
expect(restoredInstance.widgets?.[0].value).toBe(33)
widget.value = 45
// Override remains sticky — interior change does not leak across the
// per-instance boundary.
expect(restoredInstance.widgets?.[0].value).toBe(33)
expect(
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
).toBe(33)
})
it('preserves per-instance values when reconfigured without widgets_values', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 5)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 701 })
instance.graph!.add(instance)
const promotedWidget = instance.widgets?.[0]
promotedWidget!.value = 11
widget.value = 17
const serialized = instance.serialize()
delete serialized.widgets_values
instance.configure({
...serialized,
id: instance.id,
type: subgraph.id
})
// Symmetric with super.configure(): absent widgets_values is a no-op
// for widget values; the per-instance override is preserved.
expect(instance.widgets?.[0].value).toBe(11)
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(11)
})
it('skips non-serializable source widgets during serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 10)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
// Mark the source widget as non-persistent (e.g. preview widget)
widget.serialize = false
const instance = createTestSubgraphNode(subgraph, { id: 501 })
instance.configure({
id: 501,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: []
})
const serialized = instance.serialize()
expect(serialized.widgets_values).toBeUndefined()
})
it('ignores sparse widgets_values holes when restoring promoted widget instances', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'number' },
{ name: 'second', type: 'number' }
]
})
const firstNode = new LGraphNode('First')
const firstInput = firstNode.addInput('first', 'number')
const firstWidget = firstNode.addWidget('number', 'first', 5, () => {})
firstInput.widget = { name: 'first' }
firstWidget.serialize = false
const secondNode = new LGraphNode('Second')
const secondInput = secondNode.addInput('second', 'number')
secondNode.addWidget('number', 'second', 9, () => {})
secondInput.widget = { name: 'second' }
subgraph.add(firstNode)
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstNode.inputs[0], firstNode)
subgraph.inputNode.slots[1].connect(secondNode.inputs[0], secondNode)
const instance = createTestSubgraphNode(subgraph, { id: 701 })
const widgetsValues = new Array<number | undefined>(2)
widgetsValues[1] = 11
instance.configure({
id: 701,
type: subgraph.id,
pos: [0, 0],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: {
proxyWidgets: [
['-1', 'first'],
['-1', 'second']
]
},
widgets_values: widgetsValues
})
expect(instance.widgets[0].value).toBe(5)
expect(instance.widgets[1].value).toBe(11)
})
it('ignores configure replay for promoted widgets whose concrete source is non-serializable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 10)
widget.serialize = false
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 502 })
instance.configure({
id: 502,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
expect(instance.widgets[0].value).toBe(10)
widget.value = 14
expect(instance.widgets[0].value).toBe(14)
expect(instance.widgets[0].serializeValue?.(instance, 0)).toBe(14)
})
it('serializes nested promoted widgets from the concrete source widget serialize state', () => {
const leafSubgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('value', 'number')
const concreteWidget = concreteNode.addWidget(
'number',
'value',
5,
() => {}
)
concreteInput.widget = { name: 'value' }
leafSubgraph.add(concreteNode)
leafSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
const middleNode = createTestSubgraphNode(leafSubgraph, { id: 901 })
const middleSubgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
middleSubgraph.add(middleNode)
middleNode._internalConfigureAfterSlots()
middleSubgraph.inputNode.slots[0].connect(middleNode.inputs[0], middleNode)
const innerHostNode = createTestSubgraphNode(middleSubgraph, { id: 902 })
const outerSubgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
outerSubgraph.add(innerHostNode)
innerHostNode._internalConfigureAfterSlots()
outerSubgraph.inputNode.slots[0].connect(
innerHostNode.inputs[0],
innerHostNode
)
const outerHostNode = createTestSubgraphNode(outerSubgraph, { id: 903 })
outerHostNode.graph!.add(outerHostNode)
outerHostNode.widgets[0].value = 123
expect(outerHostNode.serialize().widgets_values).toEqual([123])
concreteWidget.serialize = false
expect(outerHostNode.serialize().widgets_values).toBeUndefined()
})
it('does not clobber super.serialize() values when a concrete source widget is non-serializable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node, widget } = createNodeWithWidget('TestNode', 0)
// Mark the concrete source widget non-serializable so the merge loop skips
// index 0, letting super's value survive.
widget.serialize = false
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 801 })
instance.graph!.add(instance)
// Stub super.serialize to simulate a native widget contributing a
// positional value at index 0 (a slot the promoted view would own
// if it were serializable, but this view skips because serialize:false).
const SuperProto = Object.getPrototypeOf(Object.getPrototypeOf(instance))
const originalSerialize = SuperProto.serialize as () => {
widgets_values?: unknown[]
}
vi.spyOn(SuperProto, 'serialize').mockImplementationOnce(
function (this: typeof instance) {
const out = originalSerialize.call(this)
out.widgets_values = ['native-value']
return out
}
)
const out = instance.serialize()
expect(out.widgets_values?.[0]).toBe('native-value')
})
it('round-trips Date widget values via structuredClone (preserves type)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const instance = createTestSubgraphNode(subgraph, { id: 901 })
instance.graph!.add(instance)
const date = new Date('2025-01-01T00:00:00.000Z')
instance.widgets[0].value = { when: date }
const out = instance.serialize()
const cloned = out.widgets_values?.[0] as { when: Date } | undefined
expect(cloned?.when).toBeInstanceOf(Date)
expect(cloned?.when.getTime()).toBe(date.getTime())
})
it('preserves per-instance promoted widget values across LGraphNode.clone (copy/paste)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 0)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
const original = createTestSubgraphNode(subgraph, { id: 501 })
original.configure({
id: 501,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: ['per-instance-value']
})
expect(original.widgets[0].value).toBe('per-instance-value')
// LGraphNode.clone() invokes LiteGraph.createNode (id = -1), strips the id
// from the serialized data, then calls configure(data). The clone then
// needs to be added to a graph to receive a real id.
const clone = original.clone() as SubgraphNode | null
expect(clone).toBeTruthy()
if (!clone) throw new Error('clone failed')
original.graph!.add(clone)
expect(clone.id).not.toBe(-1)
expect(clone.widgets[0].value).toBe('per-instance-value')
expect(clone.widgets[0].serializeValue?.(clone, 0)).toBe(
'per-instance-value'
)
})
it('clears deferred widget replay when reconfigured without widgets_values before attach', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node } = createNodeWithWidget('TestNode', 5)
subgraph.add(node)
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
const detached = createTestSubgraphNode(subgraph, { id: -1 })
detached.configure({
id: -1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] },
widgets_values: [33]
})
detached.configure({
id: -1,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: { proxyWidgets: [['-1', 'widget']] }
})
detached.graph!.add(detached)
expect(detached.id).not.toBe(-1)
expect(detached.widgets[0].value).toBe(5)
expect(detached.widgets[0].serializeValue?.(detached, 0)).toBe(5)
})
})

View File

@@ -1,3 +1,5 @@
import { toRaw } from 'vue'
import type { BaseLGraph, LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -28,7 +30,10 @@ 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,
isPromotedWidgetView
@@ -44,11 +49,14 @@ import {
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { makeCompositeKey } from '@/utils/compositeKey'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -103,6 +111,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
* lifecycle to persist.
*/
private _pendingPromotions: PromotedWidgetSource[] = []
/** Widgets_values buffered during pre-attach configure(); drained in onAdded(). */
private _pendingWidgetsValuesReplay?: TWidgetValue[]
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
@@ -634,29 +644,30 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
disambiguatingSourceNodeId?: string
): string {
return disambiguatingSourceNodeId
? JSON.stringify([
? makeCompositeKey([
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
])
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
: makeCompositeKey([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
private _serializeEntries(
entries: PromotedWidgetSource[]
): (string[] | [string, string, string])[] {
return entries.map((e) =>
): SerializedProxyWidgetTuple[] {
return entries.map((e) => [
e.sourceNodeId,
e.disambiguatingSourceNodeId
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
: [e.sourceNodeId, e.sourceWidgetName]
)
? `${e.sourceNodeId}: ${e.disambiguatingSourceNodeId}: ${e.sourceWidgetName}`
: e.sourceWidgetName
])
}
private _resolveLegacyEntry(
widgetName: string
): [string, string] | undefined {
): SerializedProxyWidgetTuple | 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)
@@ -1042,6 +1053,45 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
super.configure(info)
// Replay widgets_values through promoted views to restore promoted-view
// values that super.configure() skips. When the node is not yet attached
// (`id === -1`, e.g. during `LGraphNode.clone()`), the PromotedWidgetView
// setters short-circuit, so defer the replay until `onAdded()`.
this._pendingWidgetsValuesReplay = undefined
if (info.widgets_values) {
if (this.id === -1) {
this._pendingWidgetsValuesReplay = info.widgets_values
} else {
this._replayPromotedWidgetValues(info.widgets_values)
}
}
}
private _replayPromotedWidgetValues(values: TWidgetValue[]): void {
const views = this.widgets ?? []
const limit = Math.min(views.length, values.length)
for (let i = 0; i < limit; i++) {
if (!(i in values)) continue
const view = views[i]
const resolved = isPromotedWidgetView(view)
? resolveConcretePromotedWidget(
this,
view.sourceNodeId,
view.sourceWidgetName,
view.disambiguatingSourceNodeId
)
: null
if (
resolved?.status === 'resolved' &&
resolved.resolved.widget.serialize === false
) {
continue
}
view.value = values[i]
}
}
override _internalConfigureAfterSlots() {
@@ -1303,6 +1353,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override onAdded(_graph: LGraph): void {
this._flushPendingPromotions()
this._syncPromotions()
this._flushPendingWidgetsValuesReplay()
}
private _flushPendingWidgetsValuesReplay(): void {
const pending = this._pendingWidgetsValuesReplay
if (!pending || this.id === -1) return
this._pendingWidgetsValuesReplay = undefined
this._replayPromotedWidgetValues(pending)
}
/**
* Re-projects per-instance promoted-widget override values onto interior
* widgets. Runs after lazy-creation interiors (e.g. PrimitiveNode) have
* materialized their widgets and re-applied their `widgets_values`.
*/
override onAfterGraphConfigured(): void {
if (this.id === -1) return
const widgetStore = useWidgetValueStore()
for (const view of this.widgets ?? []) {
if (!isPromotedWidgetView(view)) continue
const widgetState = widgetStore.getWidget(
this.rootGraph.id,
this.id,
view.storeName
)
if (!widgetState) continue
view.value = widgetState.value as IBaseWidget['value']
}
}
/**
@@ -1573,36 +1653,59 @@ 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.
* Projects per-instance promoted widget values into `widgets_values`
* and the canonical promotion-store snapshot into
* `properties.proxyWidgets` — both written onto the serialized output.
* The live node's `properties.proxyWidgets` is not mutated; the
* promotion store is the source of truth at runtime.
*/
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
const subgraphInput =
input._subgraphSlot ??
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const connectedWidget of connectedWidgets) {
connectedWidget.value = input._widget.value
}
}
// Write promotion store state back to properties for serialization
const entries = usePromotionStore().getPromotions(
this.rootGraph.id,
this.id
)
this.properties.proxyWidgets = this._serializeEntries(entries)
return super.serialize()
const serialized = super.serialize()
// Write the promotion-store snapshot directly onto the serialized
// output so the live properties.proxyWidgets stays untouched.
serialized.properties ??= {}
serialized.properties.proxyWidgets = this._serializeEntries(entries)
// Promoted views are skipped by super.serialize() (`serialize = false`),
// so project their per-instance values back into widgets_values.
// Respect the resolved concrete source widget's serialize flag so
// transient widgets stay unsaved through nested promotions too.
// SubgraphNode never sets `serialize_widgets`, so super.serialize()
// never produces `widgets_values` — start from an empty array.
const views = this.widgets ?? []
const merged: TWidgetValue[] = []
views.forEach((view, idx) => {
if (isPromotedWidgetView(view)) {
const resolved = resolveConcretePromotedWidget(
this,
view.sourceNodeId,
view.sourceWidgetName,
view.disambiguatingSourceNodeId
)
if (
resolved.status === 'resolved' &&
resolved.resolved.widget.serialize === false
) {
return
}
}
const value = view.value
merged[idx] =
value != null && typeof value === 'object'
? (structuredClone(toRaw(value)) as TWidgetValue)
: (value as TWidgetValue)
})
if (merged.length > 0) serialized.widgets_values = merged
return serialized
}
override clone() {
const clone = super.clone()

View File

@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { duplicateSubgraphNodeIds } from '@/lib/litegraph/src/__fixtures__/duplicateSubgraphNodeIds'
import {
LGraph,
@@ -522,15 +523,19 @@ describe('SubgraphSerialization - Data Integrity', () => {
const subgraphB = graph.subgraphs.get(DUPLICATE_ID_SUBGRAPH_B)!
const subgraphBIds = new Set(subgraphB.nodes.map((node) => String(node.id)))
const rootProxyWidgetsA = graph.getNodeById(102)?.properties?.proxyWidgets
expect(Array.isArray(rootProxyWidgetsA)).toBe(true)
for (const entry of rootProxyWidgetsA as string[][]) {
const rootProxyWidgetsA = parseProxyWidgets(
graph.getNodeById(102)?.properties?.proxyWidgets
)
expect(rootProxyWidgetsA.length).toBeGreaterThan(0)
for (const entry of rootProxyWidgetsA) {
expect(subgraphAIds.has(String(entry[0]))).toBe(true)
}
const rootProxyWidgetsB = graph.getNodeById(103)?.properties?.proxyWidgets
expect(Array.isArray(rootProxyWidgetsB)).toBe(true)
for (const entry of rootProxyWidgetsB as string[][]) {
const rootProxyWidgetsB = parseProxyWidgets(
graph.getNodeById(103)?.properties?.proxyWidgets
)
expect(rootProxyWidgetsB.length).toBeGreaterThan(0)
for (const entry of rootProxyWidgetsB) {
expect(subgraphBIds.has(String(entry[0]))).toBe(true)
}

View File

@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
ISlotType,
Subgraph,
@@ -409,7 +410,10 @@ describe('SubgraphWidgetPromotion', () => {
String(samplerNode.id)
)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
[
String(nestedNode.id),
`${nestedNode.id}: ${samplerNode.id}: noise_seed`
]
])
})
@@ -430,8 +434,9 @@ describe('SubgraphWidgetPromotion', () => {
// serialize() syncs the promotion store into properties.proxyWidgets
const serialized = hostNode.serialize()
const originalProxyWidgets = serialized.properties!
.proxyWidgets as string[][]
const originalProxyWidgets = parseProxyWidgets(
serialized.properties?.proxyWidgets
)
expect(originalProxyWidgets.length).toBeGreaterThan(0)
expect(
@@ -441,7 +446,9 @@ describe('SubgraphWidgetPromotion', () => {
// Simulate clone: create a second SubgraphNode configured from serialized data
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
const cloneProxyWidgets = parseProxyWidgets(
cloneNode.properties.proxyWidgets
)
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
expect(

View File

@@ -129,25 +129,22 @@ describe('NodeWidgets', () => {
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a'
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a'
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:20:string_a',
slotName: 'string_a'
})
const nodeData = createMockNodeData('SubgraphNode', [
@@ -165,18 +162,16 @@ describe('NodeWidgets', () => {
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a',
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a',
options: { hidden: false }
})
@@ -194,17 +189,15 @@ describe('NodeWidgets', () => {
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a'
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
nodeId: 'host-node',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a'
})
const nodeData = createMockNodeData('SubgraphNode', [
@@ -217,24 +210,20 @@ describe('NodeWidgets', () => {
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
it('keeps same-name promoted entries distinct by instance widget identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
name: 'string_a',
storeName: 'string_a',
instanceWidgetName: 'source:18:string_a',
slotName: 'string_a',
type: 'text',
sourceExecutionId: '65:18'
type: 'text'
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
name: 'string_a',
storeName: 'string_a',
instanceWidgetName: 'source:19:string_a',
slotName: 'string_a',
type: 'text',
sourceExecutionId: '65:19'
type: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
@@ -250,17 +239,15 @@ describe('NodeWidgets', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:1',
storeNodeId: 'outer-subgraph:1',
storeName: 'text',
nodeId: 'host-node',
instanceWidgetName: 'promoted:text:1',
slotName: 'text'
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:2',
storeNodeId: 'outer-subgraph:2',
storeName: 'text',
nodeId: 'host-node',
instanceWidgetName: 'promoted:text:2',
slotName: 'text'
})
@@ -302,8 +289,16 @@ describe('NodeWidgets', () => {
it('keeps AppInput ids mapped to node identity for selection', () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({ nodeId: 'test_node', name: 'seed_a', type: 'text' }),
createMockWidget({ nodeId: 'test_node', name: 'seed_b', type: 'text' })
createMockWidget({
nodeId: 'test_node',
name: 'seed_a',
type: 'text'
}),
createMockWidget({
nodeId: 'test_node',
name: 'seed_b',
type: 'text'
})
])
const { container } = render(NodeWidgets, {
@@ -333,6 +328,6 @@ describe('NodeWidgets', () => {
el.getAttribute('data-id')
)
expect(ids).toStrictEqual(['test_node', 'test_node'])
expect(ids).toStrictEqual(['1', '1'])
})
})

View File

@@ -14,6 +14,7 @@ import { usePromotionStore } from '@/stores/promotionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { makeCompositeKey } from '@/utils/compositeKey'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
@@ -42,10 +43,10 @@ const createMockWidget = (
})
describe('getWidgetIdentity', () => {
it('returns stable dedupeIdentity for widgets with storeNodeId', () => {
it('returns stable dedupeIdentity for widgets with nodeId and instanceWidgetName', () => {
const widget = createMockWidget({
storeNodeId: 'subgraph:19',
storeName: 'text',
nodeId: 'subgraph:19',
instanceWidgetName: 'text',
slotName: 'text',
type: 'text'
})
@@ -54,24 +55,26 @@ describe('getWidgetIdentity', () => {
expect(renderKey).toBe(dedupeIdentity)
})
it('returns transient renderKey for widgets without stable identity', () => {
it('returns transient renderKey when neither widget nor caller provide a stable identity', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
const { dedupeIdentity, renderKey } = getWidgetIdentity(
widget,
undefined,
3
)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
it('uses sourceExecutionId for identity when no node id is available', () => {
const widget = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
sourceExecutionId: '65:18'
})
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
const { dedupeIdentity } = getWidgetIdentity(widget, undefined, 0)
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
})
})
@@ -211,9 +214,9 @@ describe('computeProcessedWidgets borderStyle', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
nodeId: '3',
instanceWidgetName: 'promoted:text:1',
source: { sourceNodeId: '1', sourceWidgetName: 'text' },
slotName: 'text'
})
@@ -247,13 +250,58 @@ describe('computeProcessedWidgets borderStyle', () => {
).toBe(true)
})
it('uses disambiguatingSourceNodeId when checking promoted border styling', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: '3',
instanceWidgetName: 'promoted:text:1',
source: {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
},
slotName: 'text'
})
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const result = computeProcessedWidgets({
nodeData: {
id: '3',
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(true)
expect(result[0]?.id).toBe('3')
})
it('does not apply promoted border styling to outermost widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
nodeId: '4',
instanceWidgetName: 'promoted:text:1',
source: { sourceNodeId: '1', sourceWidgetName: 'text' },
slotName: 'text'
})
@@ -323,8 +371,7 @@ describe('computeProcessedWidgets borderStyle', () => {
name: 'text',
type: 'combo',
nodeId: '1',
storeNodeId: '1',
storeName: 'text',
instanceWidgetName: 'text',
slotName: 'text',
options: { hidden: true }
})
@@ -333,8 +380,7 @@ describe('computeProcessedWidgets borderStyle', () => {
name: 'text',
type: 'combo',
nodeId: '1',
storeNodeId: '1',
storeName: 'text',
instanceWidgetName: 'text',
slotName: 'text'
})
@@ -362,6 +408,162 @@ describe('computeProcessedWidgets borderStyle', () => {
})
})
describe('per-instance value lookup for promoted widgets', () => {
const GRAPH_ID = 'graph-test'
const INTERIOR_NODE_ID = '5'
const INTERIOR_WIDGET_NAME = 'text'
const STORE_NAME = makeCompositeKey([
INTERIOR_NODE_ID,
INTERIOR_WIDGET_NAME,
''
])
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function buildPromotedWidget(instanceId: string): SafeWidgetData {
return createMockWidget({
name: INTERIOR_WIDGET_NAME,
type: 'text',
slotName: INTERIOR_WIDGET_NAME,
nodeId: instanceId,
instanceWidgetName: STORE_NAME,
source: {
sourceNodeId: INTERIOR_NODE_ID,
sourceWidgetName: INTERIOR_WIDGET_NAME
},
sourceNodeLocatorId: `subgraph-def:${INTERIOR_NODE_ID}`
})
}
function processInstance(
instanceId: string,
widget: SafeWidgetData
): ReturnType<typeof computeProcessedWidgets> {
return computeProcessedWidgets({
nodeData: {
id: instanceId,
type: 'SubgraphNode',
widgets: [widget],
title: 'Subgraph Instance',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
}
it('reads per-instance values from per-instance WidgetState entries when multiple instances share a definition', () => {
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(GRAPH_ID, {
nodeId: '100',
name: STORE_NAME,
type: 'text',
value: 'valueA',
options: {}
})
widgetValueStore.registerWidget(GRAPH_ID, {
nodeId: '200',
name: STORE_NAME,
type: 'text',
value: 'valueB',
options: {}
})
const [instanceA] = processInstance('100', buildPromotedWidget('100'))
const [instanceB] = processInstance('200', buildPromotedWidget('200'))
expect(instanceA.value).toBe('valueA')
expect(instanceB.value).toBe('valueB')
})
it('produces distinct dedupe identities per instance so duplicate-pruning does not collapse them', () => {
const widgetA = buildPromotedWidget('100')
const widgetB = buildPromotedWidget('200')
const identityA = getWidgetIdentity(widgetA, '100', 0)
const identityB = getWidgetIdentity(widgetB, '200', 0)
expect(identityA.dedupeIdentity).not.toBe(identityB.dedupeIdentity)
})
it('falls back to interior WidgetState value when per-instance override is absent for a promoted widget', () => {
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(GRAPH_ID, {
nodeId: INTERIOR_NODE_ID,
name: INTERIOR_WIDGET_NAME,
type: 'text',
value: 'interior-value',
options: {}
})
const promotedWidget = createMockWidget({
name: INTERIOR_WIDGET_NAME,
type: 'text',
slotName: INTERIOR_WIDGET_NAME,
nodeId: '57',
instanceWidgetName: 'STORE',
source: {
sourceNodeId: INTERIOR_NODE_ID,
sourceWidgetName: INTERIOR_WIDGET_NAME
}
})
const [result] = processInstance('57', promotedWidget)
expect(result.value).toBe('interior-value')
})
})
describe('ordinary widget dedupe', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('does not collapse duplicate ordinary widget refs in node.widgets[]', () => {
// Ordinary widgets do NOT carry their own identity (no widget.nodeId,
// no instanceWidgetName). When a host node legitimately renders the
// same widget twice, both must produce distinct entries with distinct
// renderKeys.
const ordinaryWidget = createMockWidget({
name: 'seed',
type: 'number',
nodeId: undefined,
instanceWidgetName: undefined
})
const result = computeProcessedWidgets({
nodeData: {
id: '1',
type: 'TestNode',
widgets: [ordinaryWidget, ordinaryWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result).toHaveLength(2)
expect(result[0].renderKey).not.toBe(result[1].renderKey)
})
})
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const GRAPH_ID = 'graph-test'
const NODE_ID = '1'

View File

@@ -126,8 +126,12 @@ export function getWidgetIdentity(
dedupeIdentity?: string
renderKey: string
} {
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
// Only widgets that carry their own identity (promoted widget views set
// `widget.nodeId`) get a stable dedupe identity. Ordinary widgets must
// fall through to the transient renderKey form so duplicate refs in
// `node.widgets[]` are not collapsed into one entry.
const rawWidgetId = widget.nodeId
const storeWidgetName = widget.instanceWidgetName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
@@ -187,6 +191,7 @@ export function computeProcessedWidgets({
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
perInstanceWidgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
@@ -195,13 +200,24 @@ export function computeProcessedWidgets({
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
const storeWidgetName = widget.instanceWidgetName ?? widget.name
const bareWidgetId = String(stripGraphPrefix(widget.nodeId ?? nodeId ?? ''))
const perInstanceWidgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
// For freshly-created promoted widget views the per-instance host
// override may not be registered yet. Fall back to the interior source
// WidgetState entry for reads only — the write path keeps using the
// per-instance override.
const fallbackWidgetState =
graphId && widget.source && !perInstanceWidgetState
? widgetValueStore.getWidget(
graphId,
widget.source.sourceNodeId,
widget.source.sourceWidgetName
)
: undefined
const widgetState = perInstanceWidgetState ?? fallbackWidgetState
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
@@ -213,6 +229,7 @@ export function computeProcessedWidgets({
identity,
mergedOptions,
widgetState,
perInstanceWidgetState,
isVisible: visible
})
continue
@@ -226,6 +243,7 @@ export function computeProcessedWidgets({
identity,
mergedOptions,
widgetState,
perInstanceWidgetState,
isVisible: visible
})
continue
@@ -238,6 +256,7 @@ export function computeProcessedWidgets({
identity,
mergedOptions,
widgetState,
perInstanceWidgetState,
isVisible: true
}
}
@@ -247,14 +266,21 @@ export function computeProcessedWidgets({
widget,
mergedOptions,
widgetState,
perInstanceWidgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
const hostBareId = String(stripGraphPrefix(nodeId ?? ''))
const sourceNodeId = widget.source?.sourceNodeId
const widgetId = sourceNodeId ?? hostBareId
// Promotion-store grouping keys off the interior source identity.
// For promoted views: use the source local id; for non-promoted
// widgets: the host node id stands in (a non-promoted widget acts as
// its own source for border-style purposes).
const promotionLookupNodeId =
widget.source?.disambiguatingSourceNodeId ?? sourceNodeId ?? hostBareId
const promotionSourceNodeId = widget.source
? promotionLookupNodeId
: undefined
const vueComponent =
@@ -274,7 +300,7 @@ export function computeProcessedWidgets({
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
sourceWidgetName: widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
@@ -290,11 +316,12 @@ export function computeProcessedWidgets({
}
: undefined
const nodeLocatorId = widget.nodeId
? widget.nodeId
: nodeData
? getLocatorIdFromNodeData(nodeData)
: undefined
// Locator points at the source node for promoted views (canvas/save
// flows resolve through the interior identity), and falls back to
// the host node's locator for non-promoted widgets.
const nodeLocatorId =
widget.sourceNodeLocatorId ??
(nodeData ? getLocatorIdFromNodeData(nodeData) : undefined)
const simplified: SimplifiedWidget = {
name: widget.name,
@@ -311,7 +338,7 @@ export function computeProcessedWidgets({
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
perInstanceWidgetState,
widget,
nodeExecId,
widgetOptions,
@@ -323,13 +350,7 @@ export function computeProcessedWidgets({
e.preventDefault()
e.stopPropagation()
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? String(stripGraphPrefix(widget.nodeId))
: undefined
)
showNodeOptions(e, widget.name, widget.source?.sourceNodeId)
}
result.push({
@@ -344,7 +365,7 @@ export function computeProcessedWidgets({
missingModelStore
),
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
id: widgetId,
name: widget.name,
renderKey,
type: widget.type,

View File

@@ -4,12 +4,22 @@ import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LGraphNode as LiteGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
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 { usePromotionStore } from '@/stores/promotionStore'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
@@ -26,7 +36,12 @@ const mockEmptyWorkflowDialog = vi.hoisted(() => {
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { extra: {}, nodes: [{ id: 1 }], events: new EventTarget() }
rootGraph: {
extra: {},
nodes: [{ id: 1, isSubgraphNode: () => false }],
events: new EventTarget(),
getNodeById: () => undefined
}
}
}))
@@ -47,6 +62,16 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
widgetStates: new Map(),
setPositionOverride: vi.fn(),
clearPositionOverride: vi.fn()
})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
const mockSettings = vi.hoisted(() => {
const store: Record<string, unknown> = {}
@@ -68,6 +93,35 @@ vi.mock('@/platform/settings/settingStore', () => ({
import { useAppModeStore } from './appModeStore'
function createPromotedWidgetFixture(hostId: number): {
graph: LGraph
host: SubgraphNode
promoted: PromotedWidgetView
} {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: '*' }]
})
const inner = new LiteGraphNode('Inner')
const input = inner.addInput('value', '*')
inner.addWidget('text', 'value', 'a', () => {})
input.widget = { name: 'value' }
subgraph.add(inner)
subgraph.inputNode.slots[0].connect(input, inner)
const host = createTestSubgraphNode(subgraph, { id: hostId })
host._internalConfigureAfterSlots()
host.graph!.add(host)
usePromotionStore().setPromotions(host.rootGraph.id, host.id, [
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
])
const promoted = host.widgets.find(isPromotedWidgetView)
if (!promoted) throw new Error('Expected promoted widget view')
return { graph: host.graph!, host, promoted }
}
function createBuilderWorkflow(
activeMode: string = 'builder:inputs'
): LoadedComfyWorkflow {
@@ -106,10 +160,13 @@ describe('appModeStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
vi.mocked(app.rootGraph).extra = {}
mockResolveNode.mockReturnValue(undefined)
mockSettings.reset()
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
vi.mocked(app.rootGraph).nodes = [
{ id: 1, isSubgraphNode: () => false } as LGraphNode
]
workflowStore = useWorkflowStore()
store = useAppModeStore()
vi.clearAllMocks()
@@ -258,6 +315,105 @@ describe('appModeStore', () => {
expect(store.selectedInputs).toEqual([[1, 'prompt', { height: 150 }]])
})
it('loadSelections rewrites legacy promoted tuples to host node id and storeName', async () => {
const { graph, host, promoted } = createPromotedWidgetFixture(500)
const { resolveNode: actualResolveNode } = (await vi.importActual(
'@/utils/litegraphUtil'
)) as {
resolveNode: (nodeId: NodeId, graph: LGraph) => LGraphNode | undefined
}
const originalRootGraph = app.rootGraph
mockResolveNode.mockImplementation((id) => actualResolveNode(id, graph))
Object.defineProperty(app, 'rootGraph', { value: graph, writable: true })
try {
store.loadSelections({
inputs: [[promoted.sourceNodeId, promoted.sourceWidgetName]],
outputs: []
})
expect(store.selectedInputs).toEqual([[host.id, promoted.storeName]])
} finally {
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
}
})
it('preserves selected promoted-widget identity per instance across save/reload', async () => {
// Build one subgraph definition with one promoted widget,
// then create two SubgraphNode instances of that definition.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: '*' }]
})
const inner = new LiteGraphNode('Inner')
const input = inner.addInput('value', '*')
inner.addWidget('text', 'value', 'a', () => {})
input.widget = { name: 'value' }
subgraph.add(inner)
subgraph.inputNode.slots[0].connect(input, inner)
const hostA = createTestSubgraphNode(subgraph, { id: 701 })
hostA._internalConfigureAfterSlots()
hostA.graph!.add(hostA)
const hostB = createTestSubgraphNode(subgraph, { id: 702 })
hostB._internalConfigureAfterSlots()
hostB.graph!.add(hostB)
const promotionStore = usePromotionStore()
promotionStore.setPromotions(hostA.rootGraph.id, hostA.id, [
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
])
promotionStore.setPromotions(hostB.rootGraph.id, hostB.id, [
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
])
const promotedA = hostA.widgets.find(isPromotedWidgetView)
const promotedB = hostB.widgets.find(isPromotedWidgetView)
if (!promotedA || !promotedB) throw new Error('Expected promoted views')
// Precondition: storeNames are equal (interior identity matches),
// host ids differ.
expect(promotedA.storeName).toBe(promotedB.storeName)
expect(hostA.id).not.toBe(hostB.id)
const { resolveNode: actualResolveNode } = (await vi.importActual(
'@/utils/litegraphUtil'
)) as {
resolveNode: (nodeId: NodeId, graph: LGraph) => LGraphNode | undefined
}
const graph = hostA.graph!
const originalRootGraph = app.rootGraph
mockResolveNode.mockImplementation((id) => actualResolveNode(id, graph))
Object.defineProperty(app, 'rootGraph', { value: graph, writable: true })
try {
store.loadSelections({
inputs: [
[hostA.id, promotedA.storeName],
[hostB.id, promotedB.storeName]
],
outputs: []
})
expect(store.selectedInputs).toHaveLength(2)
expect(store.selectedInputs).toEqual(
expect.arrayContaining([
[hostA.id, promotedA.storeName],
[hostB.id, promotedB.storeName]
])
)
} finally {
Object.defineProperty(app, 'rootGraph', {
value: originalRootGraph,
writable: true
})
}
})
it('keeps inputs for existing nodes even if widget is missing', async () => {
const node1 = mockNode(1)
mockResolveNode.mockImplementation((id) =>
@@ -443,6 +599,18 @@ describe('appModeStore', () => {
})
})
describe('removeSelectedInput', () => {
it('uses host node id and promoted storeName', () => {
const { host, promoted } = createPromotedWidgetFixture(601)
store.selectedInputs = [[host.id, promoted.storeName]]
store.removeSelectedInput(promoted, host)
expect(store.selectedInputs).toEqual([])
})
})
describe('autoEnableVueNodes', () => {
it('enables Vue nodes when entering select mode with them disabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false

View File

@@ -4,7 +4,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 type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
InputWidgetConfig,
LinearData,
@@ -16,9 +16,12 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
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 {
getSelectedWidgetIdentity,
resolveNode,
resolveNodeWidget
} from '@/utils/litegraphUtil'
export function nodeTypeValidForApp(type: string) {
return !['Note', 'MarkdownNote'].includes(type)
@@ -46,14 +49,37 @@ 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.
function normalizeSelectedInput(input: LinearInput): LinearInput {
const [nodeId, widgetName, config] = input
if (!app.rootGraph) return input
const resolved = resolveNodeWidget(nodeId, widgetName, app.rootGraph)
if (resolved.length < 2) return input
const node = resolved[0]
const widget = resolved[1]
if (!node || !widget) return input
const [canonicalNodeId, canonicalWidgetName] = getSelectedWidgetIdentity(
node,
widget
)
return config === undefined
? [canonicalNodeId, canonicalWidgetName]
: [canonicalNodeId, canonicalWidgetName, config]
}
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
const rawInputs = data?.inputs ?? []
const rawOutputs = data?.outputs ?? []
const normalizedInputs = app.rootGraph
? rawInputs.map(normalizeSelectedInput)
: rawInputs
return {
inputs: app.rootGraph
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
: rawInputs,
? normalizedInputs.filter(([nodeId]) => resolveNode(nodeId))
: normalizedInputs,
outputs: app.rootGraph
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
: rawOutputs
@@ -153,11 +179,8 @@ export const useAppModeStore = defineStore('appMode', () => {
setMode('graph')
}
function removeSelectedInput(widget: IBaseWidget, node: { id: NodeId }) {
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
function removeSelectedInput(widget: IBaseWidget, node: LGraphNode) {
const [storeId, storeName] = getSelectedWidgetIdentity(node, widget)
const index = selectedInputs.value.findIndex(
([id, name]) => storeId == id && storeName === name
)

View File

@@ -60,6 +60,16 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return widgetStates.get(key) as WidgetState<TValue>
}
/** First registration wins; later `state` seeds are discarded. */
function getOrRegister(graphId: UUID, state: WidgetState): WidgetState {
const widgetStates = getWidgetStateMap(graphId)
const key = makeKey(state.nodeId, state.name)
const existing = widgetStates.get(key)
if (existing) return existing
widgetStates.set(key, state)
return state
}
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
const widgetStates = getWidgetStateMap(graphId)
const prefix = `${nodeId}:`
@@ -82,6 +92,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return {
registerWidget,
getOrRegister,
getWidget,
getNodeWidgets,
clearGraph

View File

@@ -7,6 +7,7 @@ import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { makeCompositeKey } from '@/utils/compositeKey'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -75,7 +76,7 @@ export const useFavoritedWidgetsStore = defineStore('favoritedWidgets', () => {
* Generate a unique string key for a favorited widget ID.
*/
function getFavoriteKey(id: FavoritedWidgetId): string {
return JSON.stringify([id.nodeLocatorId, id.widgetName])
return makeCompositeKey([id.nodeLocatorId, id.widgetName])
}
/**

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { makeCompositeKey } from './compositeKey'
describe('makeCompositeKey', () => {
it('produces a stable string for a tuple of values', () => {
expect(makeCompositeKey(['a', 'b', 'c'])).toBe('["a","b","c"]')
})
it('distinguishes tuples whose joined parts collide', () => {
// Without an injective encoding, ['ab', 'c'] and ['a', 'bc'] could collide.
expect(makeCompositeKey(['ab', 'c'])).not.toBe(
makeCompositeKey(['a', 'bc'])
)
})
it('handles empty parts and undefined slots', () => {
expect(makeCompositeKey(['x', '', 'y'])).toBe('["x","","y"]')
expect(makeCompositeKey(['x', undefined, 'y'])).toBe('["x",null,"y"]')
})
it('preserves part order', () => {
expect(makeCompositeKey(['1', '2'])).not.toBe(makeCompositeKey(['2', '1']))
})
})

10
src/utils/compositeKey.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Build an opaque composite-key string from a tuple of values, suitable for
* use as a Map or Set key. Uses JSON.stringify so the encoding is injective
* across arbitrary string inputs (no separator collision possible). Keep the
* format opaque at consumer boundaries — do not parse it externally except
* in modules that own the round-trip (e.g. favoritedWidgetsStore).
*/
export function makeCompositeKey(parts: readonly unknown[]): string {
return JSON.stringify(parts)
}

View File

@@ -0,0 +1,170 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { makeCompositeKey } from '@/utils/compositeKey'
import { graphToPrompt } from './executionUtil'
describe('graphToPrompt with promoted subgraph widgets (PR #11811)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
it('emits the user-edited promoted value, not the interior default', async () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
// Interior node with a text widget "value" defaulting to "interior"
const interiorNode = new LGraphNode('Interior')
interiorNode.addWidget('text', 'value', 'interior', () => {})
subgraph.add(interiorNode)
// SubgraphNode instance in the root graph
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph
})
rootGraph.add(subgraphNode)
// Promote the interior widget (mirrors proxyWidgets=[["<id>","value"]])
usePromotionStore().promote(rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'value'
})
// User-edits the exterior promoted widget to "exterior". This is the same
// path the Vue widget update handler exercises in production.
const view = subgraphNode.widgets[0] as PromotedWidgetView | undefined
if (!view) throw new Error('Expected a promoted view on the SubgraphNode')
view.value = 'exterior'
const { output } = await graphToPrompt(rootGraph)
const execId = `${subgraphNode.id}:${interiorNode.id}`
expect(output[execId]).toBeDefined()
expect(output[execId].inputs.value).toBe('exterior')
})
it('isolates promoted values across two SubgraphNode instances of the same Subgraph', async () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const interiorNode = new LGraphNode('Interior')
interiorNode.addWidget('text', 'value', 'interior', () => {})
subgraph.add(interiorNode)
const instanceA = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph
})
rootGraph.add(instanceA)
const instanceB = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph
})
rootGraph.add(instanceB)
const promotionStore = usePromotionStore()
promotionStore.promote(rootGraph.id, instanceA.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'value'
})
promotionStore.promote(rootGraph.id, instanceB.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'value'
})
const storeName = makeCompositeKey([String(interiorNode.id), 'value', ''])
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(rootGraph.id, {
nodeId: instanceA.id,
name: storeName,
type: 'text',
value: 'A-value',
options: {}
})
widgetStore.registerWidget(rootGraph.id, {
nodeId: instanceB.id,
name: storeName,
type: 'text',
value: 'B-value',
options: {}
})
const { output } = await graphToPrompt(rootGraph)
expect(output[`${instanceA.id}:${interiorNode.id}`].inputs.value).toBe(
'A-value'
)
expect(output[`${instanceB.id}:${interiorNode.id}`].inputs.value).toBe(
'B-value'
)
})
it('emits the per-instance promoted value for a lazy-creation interior (PrimitiveNode-like)', async () => {
// Mimics PrimitiveNode lazy widget creation (src/extensions/core/widgetInputs.ts:32+).
class LazyPrimitiveLikeNode extends LGraphNode {
constructor() {
super('LazyPrimitiveLike')
this.serialize_widgets = true
}
override onAfterGraphConfigured(): void {
if (this.widgets?.length) return
const widget = this.addWidget('text', 'value', '', () => {})
const stored = this.widgets_values
if (stored?.length) {
widget.value = stored[0] as string
}
}
}
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const interiorNode = new LazyPrimitiveLikeNode()
interiorNode.widgets_values = ['interior']
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph
})
rootGraph.add(subgraphNode)
// Configure with proxyWidgets + exterior before the interior exists.
subgraphNode.configure({
id: subgraphNode.id,
type: subgraph.id,
pos: [100, 100],
size: [200, 100],
inputs: [],
outputs: [],
mode: 0,
order: 0,
flags: {},
properties: {
proxyWidgets: [[String(interiorNode.id), 'value']]
},
widgets_values: ['exterior']
})
// Lazy materialization clobbers exterior with widgets_values=["interior"].
interiorNode.onAfterGraphConfigured()
// SubgraphNode hook re-projects the per-instance override afterward.
subgraphNode.onAfterGraphConfigured?.()
const { output } = await graphToPrompt(rootGraph)
const execId = `${subgraphNode.id}:${interiorNode.id}`
expect(output[execId]).toBeDefined()
expect(output[execId].inputs.value).toBe('exterior')
})
})

View File

@@ -1,3 +1,4 @@
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type {
ExecutableLGraphNode,
ExecutionId,
@@ -7,14 +8,95 @@ import {
ExecutableNodeDTO,
LGraphEventMode
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { makeCompositeKey } from '@/utils/compositeKey'
import { ExecutableGroupNodeDTO, isGroupNode } from './executableGroupNodeDto'
import { compressWidgetInputSlots } from './litegraphUtil'
/**
* Looks up the effective value for a flattened interior widget by walking the
* ancestor SubgraphNode chain (outermost → innermost) and returning the first
* per-instance promoted-widget override that targets this exact widget object.
*
* Mirrors the read semantics used by the Vue / canvas render paths so that
* prompt-build does not desync from the on-screen value.
*/
function resolvePromotedWidgetOverride(
node: ExecutableLGraphNode,
widget: IBaseWidget
): { hit: true; value: unknown } | { hit: false } {
if (!(node instanceof ExecutableNodeDTO)) return { hit: false }
if (node.subgraphNodePath.length === 0) return { hit: false }
const rootGraph = node.graph.rootGraph
const hosts = rootGraph.resolveSubgraphIdPath(node.subgraphNodePath)
const promotionStore = usePromotionStore()
const widgetStore = useWidgetValueStore()
for (const host of hosts) {
const entries = promotionStore.getPromotionsRef(rootGraph.id, host.id)
for (const entry of entries) {
const resolved = resolveConcretePromotedWidget(
host,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
if (resolved.status !== 'resolved') continue
if (resolved.resolved.widget !== widget) continue
const storeName = makeCompositeKey([
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId ?? ''
])
const state = widgetStore.getWidget(rootGraph.id, host.id, storeName)
if (state) return { hit: true, value: state.value }
}
}
return { hit: false }
}
/**
* Computes the value used for prompt serialization for a single widget.
* Falls back to the standard `widget.serializeValue` / `widget.value` path,
* but routes through the per-instance promoted override when one applies. When a
* custom `serializeValue` is defined, it is invoked on a proxy widget whose
* `.value` returns the override, preserving widget-specific serialization.
*/
async function getExecutableWidgetValue(
node: ExecutableLGraphNode,
widget: IBaseWidget,
index: number
): Promise<unknown> {
const override = resolvePromotedWidgetOverride(node, widget)
if (!override.hit) {
return widget.serializeValue
? await widget.serializeValue(node, index)
: widget.value
}
if (!widget.serializeValue) return override.value
const widgetProxy = Object.create(widget) as IBaseWidget
Object.defineProperty(widgetProxy, 'value', {
get: () => override.value,
set: () => {},
enumerable: true,
configurable: true
})
return await widget.serializeValue.call(widgetProxy, node, index)
}
/**
* Converts the current graph workflow for sending to the API.
* @note Node widgets are updated before serialization to prepare queueing.
@@ -99,9 +181,7 @@ export const graphToPrompt = async (
for (const [i, widget] of widgets.entries()) {
if (!widget.name || widget.options?.serialize === false) continue
const widgetValue = widget.serializeValue
? await widget.serializeValue(node, i)
: widget.value
const widgetValue = await getExecutableWidgetValue(node, widget, i)
// By default, Array values are reserved to represent node connections.
// We need to wrap the array as an object to avoid the misinterpretation
// of the array as a node connection.

View File

@@ -1,11 +1,63 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
import { resolveNode } from './litegraphUtil'
import { resolveNode, resolveNodeWidget } from './litegraphUtil'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({
widgetStates: new Map(),
setPositionOverride: vi.fn(),
clearPositionOverride: vi.fn()
})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createPromotedWidgetFixture(hostId: number) {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: '*' }]
})
const inner = new LGraphNode('Inner')
const input = inner.addInput('value', '*')
inner.addWidget('text', 'value', 'a', () => {})
input.widget = { name: 'value' }
subgraph.add(inner)
subgraph.inputNode.slots[0].connect(input, inner)
const host = createTestSubgraphNode(subgraph, { id: hostId })
host._internalConfigureAfterSlots()
host.graph!.add(host)
usePromotionStore().setPromotions(host.rootGraph.id, host.id, [
{ sourceNodeId: String(inner.id), sourceWidgetName: 'value' }
])
const promoted = host.widgets.find(isPromotedWidgetView)
if (!promoted) throw new Error('Expected promoted widget view')
return { host, promoted }
}
describe('resolveNode', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
it('returns undefined when graph is null', () => {
expect(resolveNode(1, null)).toBeUndefined()
})
@@ -67,4 +119,43 @@ describe('resolveNode', () => {
const targetNode = sg2._nodes[0]
expect(resolveNode(targetNode.id, rootGraph)).toBe(targetNode)
})
it('resolves promoted widget by host node id and storeName', () => {
const { host, promoted } = createPromotedWidgetFixture(501)
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
host.id,
promoted.storeName,
host.graph!
)
expect(resolvedNode).toBe(host)
expect(resolvedWidget).toBe(promoted)
})
it('keeps legacy fallback for saved promoted widget source tuples', () => {
const { host, promoted } = createPromotedWidgetFixture(502)
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
promoted.sourceNodeId,
promoted.sourceWidgetName,
host.graph!
)
expect(resolvedNode).toBe(host)
expect(resolvedWidget).toBe(promoted)
})
it('keeps legacy fallback for saved promoted widget source tuples with numeric node ids', () => {
const { host, promoted } = createPromotedWidgetFixture(503)
const [resolvedNode, resolvedWidget] = resolveNodeWidget(
Number(promoted.sourceNodeId),
promoted.sourceWidgetName,
host.graph!
)
expect(resolvedNode).toBe(host)
expect(resolvedWidget).toBe(promoted)
})
})

View File

@@ -328,25 +328,43 @@ export function resolveNode(
}
return undefined
}
export function getSelectedWidgetIdentity(
node: Pick<LGraphNode, 'id'>,
widget: IBaseWidget
): [NodeId, string] {
if (isPromotedWidgetView(widget)) return [node.id, widget.storeName]
return [node.id, widget.name]
}
export function resolveNodeWidget(
nodeId: NodeId,
widgetName?: string,
graph: LGraph = app.rootGraph
graph: LGraph | null | undefined = app.rootGraph
): [LGraphNode, IBaseWidget] | [LGraphNode] | [] {
if (!graph || typeof graph.getNodeById !== 'function') return []
const node = graph.getNodeById(nodeId)
const normalizedSourceNodeId = String(nodeId)
if (!widgetName) return node ? [node] : []
if (node) {
const widget = node.widgets?.find((w) => w.name === widgetName)
const widget = node.widgets?.find(
(w) =>
w.name === widgetName ||
(isPromotedWidgetView(w) && w.storeName === widgetName)
)
return widget ? [node, widget] : []
}
for (const node of graph.nodes) {
if (!node.isSubgraphNode()) continue
if (typeof node.isSubgraphNode !== 'function' || !node.isSubgraphNode())
continue
const widget = node.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
w.sourceNodeId === normalizedSourceNodeId
)
if (widget) return [node, widget]
}