Compare commits

...

28 Commits

Author SHA1 Message Date
github-actions
bf9b645439 [automated] Update test expectations 2026-05-10 05:17:11 +00:00
DrJKL
aa4c42ba7f fix(subgraph): exclude promotion plumbing links from slotMetadata.linked
After the promotion-store-to-real-link migration, promoteWidget materialises
a real link from the SUBGRAPH_INPUT sentinel into the interior widget's input
slot. The renderer's pre-existing 'linked → disabled' rule then unintentionally
fires on every promoted widget, marking the interior textarea disabled+readonly
so it cannot be edited from inside the subgraph definition.

Treat links whose origin is SUBGRAPH_INPUT_ID as internal plumbing rather
than external connections in buildSlotMetadata. This restores the editability
of promoted interior widgets when navigating into a subgraph definition while
preserving the linked semantic for actual upstream connections.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0e0d-1937-758e-8a9b-4f54716b0aa2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 17:04:26 -07:00
DrJKL
70b50d6137 fix(subgraph): isolate transient clones and host-state options
Two structural risks in the duplicate-subgraph configure path:

1. Transient clones produced during clipboard duplicate go through
   SubgraphNode.configure() with id === -1 before being added to the
   graph. _applyPromotedWidgetValues would then hydrate widget store
   entries under id -1, which can race with the eventual real instance
   for ownership of host state. Skip hydration for transient instances.

2. setHostWidgetState was registering the host widget state with a
   shared reference to the interior widget's options. Any subsequent
   host-state mutation (disabled toggle in particular) would leak into
   the shared interior across every SubgraphNode instance. Clone the
   options object so host state cannot poison the shared interior.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0e0d-1937-758e-8a9b-4f54716b0aa2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 15:58:25 -07:00
DrJKL
18845392c3 fix(app-mode): chain through onTrigger for slot-label renames
LGraph.trigger() invokes onTrigger synchronously but does not dispatch
on events. The previous useEventListener(app.rootGraph.events,
'node:slot-label:changed', ...) listener never fired, so renaming a
promoted widget in app mode never bumped the graphNodes shallowRef and
the renamed label never reached the DOM.

Chain through app.rootGraph.onTrigger (the actual delivery path used by
renameWidget and SubgraphNode renaming-input/output handlers) and
restore the previous handler in onBeforeUnmount.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0e0d-1937-758e-8a9b-4f54716b0aa2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 15:56:10 -07:00
DrJKL
199eaacf60 test(subgraph): correct legacy-prefix and preview-host assertions
- subgraphSerialization: legacy proxyWidgets entry doesn't match existing
  linked input identity, so migration creates a second SubgraphInput rather
  than deduping. Assert the row count and that no legacy prefix leaks into
  the rendered labels (the actual test intent) instead of the prior
  dedup-based count assertion.
- imagePreview: hosts whose only promoted content is preview exposures
  have empty node.widgets, so .lg-node-widgets is not rendered at all
  (gated by NodeWidgets v-if). The count-by-name assertion above already
  proves preview exposures don't render as regular widget rows.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0e0d-1937-758e-8a9b-4f54716b0aa2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 15:01:53 -07:00
DrJKL
82b1c4dd95 test(subgraph): align e2e expectations + helper with new ratchet semantics
- subgraphSerialization "Nested preview exposures": drop assertion that
  `.lg-node-widgets` lacks `$$canvas-image-preview`. A host whose only
  promoted content is a preview exposure has no `node.widgets` entries
  and renders no `.lg-node-widgets` container; the pseudo-widget surfaces
  via usePromotedPreviews. The preceding getPromotedWidgetNames poll
  already proves the exposure exists.

- subgraphSerialization "No legacy-prefixed or disconnected widgets":
  expect 1 widget row instead of 2. Migration now dedupes the legacy
  proxyWidgets entry against the existing linked input — only the
  canonical row remains.

- promotedWidgets.getPromotedWidgetCountByName: route through the
  existing getPromotedWidgets so it merges properties.previewExposures,
  matching its sibling helpers updated earlier in this branch.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0e0d-1937-758e-8a9b-4f54716b0aa2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:12:56 -07:00
DrJKL
263879a838 fix(subgraph): isolate duplicated host widget values via host-only hydration
Cloning a SubgraphNode triggers serialize -> create -> configure on the
new instance. The previous _applyPromotedWidgetValues went through
PromotedWidgetView.set value, which cascades into getLinkedInputWidgets
and resolveAtHost?.widget.value writes — stomping the shared interior
widget state across every other SubgraphNode instance referencing the
same shared interior. The DOM widget then races for ownership and the
duplicated subgraph's textarea becomes unreachable.

Add PromotedWidgetView.hydrateHostValue(value) that writes only to the
host's widget value store entry (keyed on subgraphNode.id), and rewrite
_applyPromotedWidgetValues to call it. Per-instance values stay
isolated; interior widget state is no longer touched by the configure
round-trip.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0e0d-1937-758e-8a9b-4f54716b0aa2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
3cfd4d0401 fix(subgraph): preserve renamed labels through migration + app mode
Two related label-propagation regressions:

R1 (app-mode rename): mappedSelections in AppModeWidgetList depends on
graphNodes (shallowRef), and renaming a promoted widget never bumped it.
Listen for node:slot-label:changed (already fired by renameWidget) and
triggerRef the existing graphNodes shallowRef.

R2 (sidepanel renamed labels): repairCreateSubgraphInput in the proxy-
widget migration created a new SubgraphInput without copying the
interior slot's label. After migration, the renamed label was lost
because PromotedWidgetView.label falls back to slot.name. Mirror the
LGraphNode.configure input.label propagation at the migration boundary.

Also drop the redundant `_subgraphSlot.label =` write in renameWidget
(the PromotedWidgetView.set label setter writes the slot directly).

Amp-Thread-ID: https://ampcode.com/threads/T-019e0e0d-1937-758e-8a9b-4f54716b0aa2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
231a8ce723 fix(subgraph): demoteWidget looks up host input not interior
demoteWidget previously iterated parent.subgraph.inputs (interior side)
where input._widget is the source widget, not a PromotedWidgetView. The
isPromotedWidgetView check therefore always returned false and
removeInput was never called, so un-promote silently no-op'd.

Extract findHostInputForPromotion(node, sourceNodeId, sourceWidgetName)
that iterates the host-side subgraphNode.inputs (where _widget IS the
PromotedWidgetView) and use it from both isLinkedPromotion and
demoteWidget.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0e0d-1937-758e-8a9b-4f54716b0aa2
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
32b77ae13b refactor(subgraph): drop disambiguatingSourceNodeId from canonical promoted widget shape
Treat each SubgraphNode as opaque: canonical PromotedWidgetView and
PromotedWidgetSource link parent levels to immediate child SubgraphNode
inputs rather than carrying deepest-leaf widget identity. The
disambiguator is retained only as legacy lookup metadata in migration
code via a dedicated LegacyProxyEntrySource shape.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0df9-abbb-73df-88d9-379128728306
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
d1c93c9f4f fix(subgraph): apply control-after-generate to promoted host widget
Per /temp/plans/2026-05-08-pr-12074-grilling-fixes-draft.md remaining work:

- Skip control widget application on linked widgets in addValueControlWidgets
  to avoid double-applying control after the host promoted widget runs it.
- Add beforeQueued/afterQueued on PromotedWidgetView so increment/decrement/
  randomize updates the host value (not the inert source value) post-prompt.
- Track input order in SubgraphNode promoted view caches so reorders
  invalidate correctly.
- Filter null entries in nodeOutputStore image lists.
- Guard preview/quarantine schema parsers against undefined property.
- Extract IS_CONTROL_WIDGET to controlWidgetMarker leaf module so the
  promoted view can mark-check without pulling widgets.ts -> domWidget.ts
  -> litegraph barrel cycle into module init.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0b6b-f015-712a-89fb-f5f031ed2746
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
3c33ed3095 fix: break circular import causing schema TDZ errors
apiSchema -> colorPaletteSchema -> litegraph barrel -> apiSchema cycle
left zTaskOutput/resultItemType uninitialized when nodeDefSchema and
jobTypes evaluated. Extract resultItemType to leaf module and import
RenderShape from globalEnums leaf instead of LiteGraph global.

Amp-Thread-ID: https://ampcode.com/threads/T-019e0b6b-f015-712a-89fb-f5f031ed2746
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
3c4b0d394f fix(subgraph): preserve preview host identity
Amp-Thread-ID: https://ampcode.com/threads/T-019e09ef-e07a-75be-a2c4-b6163a399c07
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
30ea677456 fix(subgraph): address promotion review gaps
Amp-Thread-ID: https://ampcode.com/threads/T-019e05c3-bed1-706a-a7a7-27733a6ab1e4
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
GitHub Action
d644b3e897 [automated] Apply ESLint and Oxfmt fixes 2026-05-09 14:11:08 -07:00
DrJKL
c0e6c1da32 chore(subgraph): remove stray docs
Amp-Thread-ID: https://ampcode.com/threads/T-019e05c3-bed1-706a-a7a7-27733a6ab1e4
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
103ec23fb9 refactor(subgraph): clean promotion vestiges
Amp-Thread-ID: https://ampcode.com/threads/T-019e05c3-bed1-706a-a7a7-27733a6ab1e4
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
cf27840343 refactor(subgraph): remove promotion store
Amp-Thread-ID: https://ampcode.com/threads/T-019e05c3-bed1-706a-a7a7-27733a6ab1e4
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
546c2cb4d4 refactor(subgraph): remove promotion store use
Amp-Thread-ID: https://ampcode.com/threads/T-019e05c3-bed1-706a-a7a7-27733a6ab1e4
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
6e25648373 chore(subgraph): prune ratchet scaffolding
Amp-Thread-ID: https://ampcode.com/threads/T-019e0162-d844-763b-8a9d-d9a06f975b62
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
cbb38a993a fix(subgraph): isolate promoted widget values
Amp-Thread-ID: https://ampcode.com/threads/T-019dffde-5ec7-7218-8d7b-85bd291c83b9
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
63e58620f2 refactor(subgraph): demote PromotionStore + remove proxyWidgets configure-time writes
ADR 0009 PR-B slice 6.

PromotionStore (src/stores/promotionStore.ts):
- Removes the parallel `graphRefCounts` ref-count map. `isPromotedByAny` is
  now derived from a `computed` projection over all entries in a graph.
- The store is documented as a runtime index rather than the canonical owner
  of promoted-widget state — the linked SubgraphInput is canonical after
  ADR 0009.
- Public mutator/reader signatures are unchanged. All 42 existing store
  tests pass; the test describe block formerly named "ref-counted
  isPromotedByAny" is renamed to "derived isPromotedByAny".

SubgraphNode._internalConfigureAfterSlots:
- Removes `this.properties.proxyWidgets ??= []` initialization and the
  resolved-entries writeback (`this.properties.proxyWidgets = serialized`).
  Together with the slice 4 serialize change, the host node no longer ever
  writes to properties.proxyWidgets — it is read-only legacy load data.
- Hydration of the runtime PromotionStore index from legacy proxyWidgets
  remains so existing consumers continue to see entries; the slice 5 flush
  forward-ratchets the same payload into canonical state right after
  configure completes.
- Drops the now-unused `_serializeEntries` helper.

Tests: updated SubgraphWidgetPromotion legacy-hydration tests to assert on
the synthetic widget surface rather than properties.proxyWidgets, since the
configure path no longer rewrites the legacy property.

Amp-Thread-ID: https://ampcode.com/threads/T-019dffa4-9428-740d-a6e8-bfa201ce504c
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:08 -07:00
DrJKL
bd50297c23 feat(subgraph): add proxyWidget migration flush orchestrator + LGraph wiring
ADR 0009 PR-B slice 5. The flush orchestrator consumes a planner's
PendingMigrationEntry list and dispatches per entry to:

- repairValueWidget for `alreadyLinked` / `createSubgraphInput` plans;
- repairPrimitiveFanout for the primitiveBypass cohort (grouped by primitive
  node id, all-or-quarantine);
- migratePreviewExposure for `previewExposure` plans (writes the host-scoped
  PreviewExposureStore);
- appendHostQuarantine for `quarantine` plans and any failed repairs.

After a successful flush, `properties.proxyWidgets` is dropped from the host
node so that re-running flush over an already-migrated host short-circuits
to a no-op (planner returns an empty plan).

Wiring: LGraph.configure invokes a late-bound migration hook for every host
SubgraphNode that still carries `properties.proxyWidgets`. The hook is
registered from app initialization (main.ts) — keeping the LGraph layer
free of any direct dependency on the PreviewExposureStore avoids a runtime
circular import. The hook receives the original ISerialisedNode so the
flush can read the host's serialized `widgets_values` (the source of host
overlay values), which would otherwise be lost after configure runs.

Idempotency tests cover preview migration, quarantine append, and the
clearing of properties.proxyWidgets after the first flush.

Amp-Thread-ID: https://ampcode.com/threads/T-019dffa4-9428-740d-a6e8-bfa201ce504c
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:07 -07:00
DrJKL
3326347be2 feat(subgraph): stop emitting proxyWidgets on serialize, write previewExposures and quarantine
ADR 0009 PR-B slice 4. SubgraphNode.serialize() no longer:
- copies host wrapper values back into interior widgets (cause of cross-host
  stomping when multiple host SubgraphNodes wrap the same Subgraph), and
- re-emits properties.proxyWidgets from the promotion store.

Instead it writes:
- properties.previewExposures from the new PreviewExposureStore (omitted when
  empty), and
- normalizes properties.proxyWidgetErrorQuarantine (omitted when empty).

The legacy properties.proxyWidgets is preserved on the host node when present
(for the upcoming load-time forward ratchet) but is no longer the canonical
source of truth at save time.

Tests:
- New SubgraphNode.serialize.test.ts asserts: removed copy-back loop is
  unreachable, proxyWidgets not re-emitted, previewExposures round-trip,
  quarantine round-trip and inert.
- Updated existing tests in subgraphNodePromotion, promotedWidgetView, and
  SubgraphWidgetPromotion to reflect the new save-time semantics; the
  configure-time hydration path is exercised by injecting the legacy
  proxyWidgets payload directly.

Amp-Thread-ID: https://ampcode.com/threads/T-019dffa4-9428-740d-a6e8-bfa201ce504c
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:07 -07:00
DrJKL
64c5550afb feat(subgraph): add proxyWidget repair helpers (value, primitive fanout, preview)
Slice 3 of PR-B for ADR 0009.

- repairValueWidget: handles alreadyLinked + createSubgraphInput plans;
  host value wins over interior; returns typed result with quarantine reason
  on failure.
- repairPrimitiveFanout: all-or-quarantine helper for one primitive's
  fan-out into one SubgraphInput; coalesces duplicate cohort entries;
  reconnects targets in slot order; rollback on partial failure.
- migratePreviewExposure: thin orchestrator over usePreviewExposureStore;
  uses createNodeLocatorId for host scoping; nextUniqueName via the store.
- Tests cover happy paths, host-value precedence, missing source/widget
  quarantine reasons, primitive coalescing + rollback, preview exposure
  collision via nextUniqueName, and a nested-host chain round-trip.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019dfe28-041d-733c-93c9-90b6bc417096
2026-05-09 14:11:07 -07:00
DrJKL
d81319ec74 feat(subgraph): add proxyWidget migration classifier + planner + quarantine helper
Slice 2 of PR-B for ADR 0009.

- classifyProxyEntry: pure classification per plan §7.3 (alreadyLinked,
  preview $$/pseudo, primitive fanout cohort, value-widget create,
  quarantine reasons missingSourceNode/missingSourceWidget/unlinkedSourceWidget).
- proxyWidgetMigrationPlanner: parse → normalize → classify → emit
  PendingMigrationEntry with sparse-hole-preserving host value mapping.
  Idempotent on already-migrated hosts.
- quarantineEntry: host-property-backed (per §14 q1: no third Pinia store);
  read/append/clear with parse-safe reads, dedup by originalEntry, omit
  property when empty. attemptedAtVersion pinned to 1.
- Tests cover each classification branch, planner idempotency, sparse host
  values, parse-error tolerance, quarantine round-trip + dedup.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019dfe28-041d-733c-93c9-90b6bc417096
2026-05-09 14:11:07 -07:00
DrJKL
10515fe75c feat(subgraph): add preview-exposure chain helpers + extend resolveChain
Slice 1 of PR-B for ADR 0009 (subgraph promoted-widget ratchet).

- src/core/graph/subgraph/preview/previewExposureTypes.ts: PreviewExposureIdentity,
  ResolvedPreviewChainStep, ResolvedPreviewChain (multi-step shape).
- src/core/graph/subgraph/preview/previewExposureIdentity.ts: makePreviewExposureIdentity,
  previewExposureIdentityEquals, previewExposureIdentityKey helpers.
- src/core/graph/subgraph/preview/previewExposureChain.ts: pure
  resolvePreviewExposureChain walker with PreviewExposureChainContext callback
  surface (graph-agnostic). Cycle detection + max-depth guard with console.warn.
- src/stores/previewExposureStore.ts: resolveChain now delegates to the chain
  walker; accepts an optional ResolveNestedHostFn so callers wire up real
  graph traversal at the call site without dragging LGraph into the store.
  Returns the new ResolvedPreviewChain shape (re-exported from preview/types).
- src/stores/previewExposureStore.test.ts: drop the PR-A "stub" assertion;
  add coverage for single-step (no resolver), one-nested-host, and
  two-nested-host (three-step) walks.
- src/core/graph/subgraph/preview/previewExposureChain.test.ts: leaf,
  one-nested, two-nested cross-root, partial-walk on missing inner exposure,
  cycle detection.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019dfe28-041d-733c-93c9-90b6bc417096
2026-05-09 14:11:07 -07:00
DrJKL
62ba80b2df feat: add subgraph promoted-widget ratchet PR-A scaffolding
Additive prep slice for ADR 0009 (subgraph promoted widgets use linked
inputs). No production code path uses these modules yet.

- previewExposureSchema: parse properties.previewExposures with the
  same warn-don't-throw + JSON-string fallback pattern as
  parseProxyWidgets
- proxyWidgetQuarantineSchema: parse properties.proxyWidgetErrorQuarantine;
  reasons enum per ADR; hostValue typed as TWidgetValue at the API
  boundary (z.unknown internally), attemptedAtVersion pinned to 1
- previewExposureStore: host-scoped Pinia store keyed by
  (rootGraphId, hostNodeLocator); add/set/remove/move/clearGraph;
  resolveChain stubbed to single link (full nested-host walk in PR-B)
- Export serializedProxyWidgetTupleSchema + SerializedProxyWidgetTuple
  from promotionSchema so quarantine entries can re-use the existing
  tuple shape

Amp-Thread-ID: https://ampcode.com/threads/T-019dfad3-5f1f-7249-8fd0-e59d36099186
Co-authored-by: Amp <amp@ampcode.com>
2026-05-09 14:11:07 -07:00
93 changed files with 7265 additions and 6165 deletions

View File

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

View File

@@ -27,7 +27,7 @@ export async function getPromotedWidgets(
// Read the live promoted widget views from the host node instead of the
// serialized proxyWidgets snapshot, which can lag behind the current graph
// state during promotion and cleanup flows.
return widgets.flatMap((widget) => {
const widgetEntries = widgets.flatMap((widget) => {
if (
widget &&
typeof widget === 'object' &&
@@ -40,6 +40,29 @@ export async function getPromotedWidgets(
}
return []
})
const serialized = window.app!.graph!.serialize()
const serializedNode = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
const previewExposures = serializedNode?.properties?.previewExposures
const previewEntries = Array.isArray(previewExposures)
? previewExposures.flatMap((exposure) => {
if (
typeof exposure === 'object' &&
exposure !== null &&
'sourceNodeId' in exposure &&
typeof exposure.sourceNodeId === 'string' &&
'sourcePreviewName' in exposure &&
typeof exposure.sourcePreviewName === 'string'
) {
return [[exposure.sourceNodeId, exposure.sourcePreviewName]]
}
return []
})
: []
return [...widgetEntries, ...previewEntries]
}, nodeId)
return normalizePromotedWidgets(raw)
@@ -78,12 +101,6 @@ export async function getPromotedWidgetCountByName(
nodeId: string,
widgetName: string
): Promise<number> {
return comfyPage.page.evaluate(
([id, name]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
return widgets.filter((widget) => widget.name === name).length
},
[nodeId, widgetName] as const
)
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.filter(([, name]) => name === widgetName).length
}

View File

@@ -1,20 +1,149 @@
import { readFileSync } from 'fs'
import { expect } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { TestIds } from '@e2e/fixtures/selectors'
import { assetPath } from '@e2e/fixtures/utils/paths'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
import {
getPromotedWidgetCount,
getPromotedWidgetNames,
getPromotedWidgets
} from '@e2e/fixtures/utils/promotedWidgets'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
interface MutableWorkflowNode {
id: number
pos?: [number, number]
widgets_values?: unknown[]
properties?: Record<string, unknown>
}
type MutableWorkflow = ComfyWorkflowJSON & {
last_node_id: number
nodes: MutableWorkflowNode[]
}
interface HostWidgetSnapshot {
name: string
sourceNodeId: string | null
sourceWidgetName: string | null
value: unknown
}
interface PrimitiveFanoutSnapshot {
hostWidgetNames: string[]
hostWidgetValues: HostWidgetSnapshot[]
interiorWidgetValues: unknown[]
primitiveOutputLinks: unknown
primitiveOriginLinkCount: number
serializedProperties: Record<string, unknown>
}
function loadPrimitiveFanoutWorkflow(): MutableWorkflow {
return JSON.parse(
readFileSync(
assetPath('subgraphs/subgraph-with-link-and-proxied-primitive.json'),
'utf-8'
)
) as MutableWorkflow
}
function createPrimitiveFanoutMultiHostWorkflow(): ComfyWorkflowJSON {
const workflow = loadPrimitiveFanoutWorkflow()
const original = workflow.nodes.find((node) => node.id === 2)
if (!original) throw new Error('Primitive fanout fixture is missing host 2')
original.widgets_values = ['first-host', 11]
const clone = structuredClone(original)
clone.id = 12
clone.pos = [900, 409]
clone.widgets_values = ['second-host', 22]
workflow.nodes.push(clone)
workflow.last_node_id = Math.max(workflow.last_node_id, clone.id)
return workflow
}
function createUnresolvableProxyWorkflow(): ComfyWorkflowJSON {
const workflow = loadPrimitiveFanoutWorkflow()
const host = workflow.nodes.find((node) => node.id === 2)
if (!host) throw new Error('Primitive fanout fixture is missing host 2')
host.properties = {
...host.properties,
proxyWidgets: [['9999', 'missing_widget']]
}
host.widgets_values = ['quarantined-host-value']
return workflow
}
async function getPrimitiveFanoutSnapshot(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<PrimitiveFanoutSnapshot> {
return comfyPage.page.evaluate((id) => {
const graph = window.app!.canvas.graph!
const hostNode = graph.getNodeById(Number(id))
if (!hostNode?.isSubgraphNode?.()) {
throw new Error(`Host node ${id} is not a SubgraphNode`)
}
const primitiveNode = hostNode.subgraph.getNodeById(4)
const primitiveOriginLinkCount = [
...hostNode.subgraph._links.values()
].filter((link) => link.origin_id === 4).length
const serialized = window.app!.graph!.serialize()
const serializedNode = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return {
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
name: widget.name,
sourceNodeId:
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
? widget.sourceNodeId
: null,
sourceWidgetName:
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
? widget.sourceWidgetName
: null,
value: widget.value
})),
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
(node.widgets ?? []).map((widget) => widget.value)
),
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
primitiveOriginLinkCount,
serializedProperties: serializedNode?.properties ?? {}
}
}, hostNodeId)
}
async function getSerializedSubgraphNodeProperties(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<Record<string, unknown>> {
return comfyPage.page.evaluate((id) => {
const serialized = window.app!.graph!.serialize()
const node = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return node?.properties ?? {}
}, hostNodeId)
}
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
@@ -41,6 +170,160 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test('Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.toBeGreaterThan(1)
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(beforeReload.hostWidgetNames).toContain('value')
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
expect(beforeReload.serializedProperties).not.toHaveProperty('proxyWidgets')
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgetErrorQuarantine'
)
await comfyPage.subgraph.serializeAndReload()
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(afterReload.interiorWidgetValues).toEqual(
beforeReload.interiorWidgetValues
)
expect(
afterReload.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe(
beforeReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
)
expect(afterReload.primitiveOriginLinkCount).toBe(0)
expect(afterReload.serializedProperties).not.toHaveProperty('proxyWidgets')
})
test('Multiple SubgraphNode hosts keep independent migrated widget values', async ({
comfyPage
}) => {
await comfyPage.workflow.loadGraphData(
createPrimitiveFanoutMultiHostWorkflow()
)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.toBeGreaterThan(1)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '12'))
.toBeGreaterThan(1)
const firstHost = await getPrimitiveFanoutSnapshot(comfyPage, '2')
const secondHost = await getPrimitiveFanoutSnapshot(comfyPage, '12')
expect(
firstHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe('first-host')
expect(
firstHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe('first-host')
expect(
secondHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe('second-host')
expect(
secondHost.hostWidgetValues.find((widget) => widget.sourceNodeId === '1')
?.value
).toBe('second-host')
await comfyPage.subgraph.serializeAndReload()
const firstAfterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
const secondAfterReload = await getPrimitiveFanoutSnapshot(comfyPage, '12')
expect(
firstAfterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe('first-host')
expect(
firstAfterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe('first-host')
expect(
secondAfterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe('second-host')
expect(
secondAfterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe('second-host')
})
test('Nested preview exposures render through serialized chain resolution', async ({
comfyPage
}) => {
test.setTimeout(45_000)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.vueNodes.waitForNodes()
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'8'
)
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
expect(nestedHostProperties.previewExposures).toEqual([
expect.objectContaining({
sourceNodeId: '6',
sourcePreviewName: '$$canvas-image-preview'
})
])
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
await expect(nestedSubgraphNode).toBeVisible()
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
.toContain('$$canvas-image-preview')
// A host whose only promoted content is a preview exposure has no
// node.widgets entries and renders no `.lg-node-widgets` container; the
// pseudo-widget surfaces via usePromotedPreviews instead.
})
test('Legacy unresolvable proxy entry is omitted and quarantined on save', async ({
comfyPage
}) => {
await comfyPage.workflow.loadGraphData(createUnresolvableProxyWorkflow())
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
.not.toContain('missing_widget')
const serializedProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'2'
)
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'missing_widget'],
reason: 'missingSourceNode',
hostValue: 'quarantined-host-value'
})
])
})
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
@@ -487,14 +770,15 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
// The legacy `proxyWidgets` entry references an interior nodeId that
// doesn't match the existing linked input's PromotedWidgetView source,
// so migration creates a second SubgraphInput rather than deduping.
// The intent of this test is that no legacy "<id>: <id>:" prefix
// leaks into the rendered widget rows.
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
await expect(widgetRows.first()).not.toContainText('6: 3:')
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
})
}
)

View File

@@ -93,12 +93,11 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
)
.toBe(1)
await expect(
firstSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await expect(
secondSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
// Hosts whose only promoted content is preview exposures have empty
// node.widgets, so the `.lg-node-widgets` container is not rendered at
// all (gated by `<NodeWidgets v-if="nodeData.widgets?.length">`). The
// assertion above (count by name returns the right number) already
// proves previews don't render as regular widget rows.
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,10 +3,10 @@ import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -70,8 +70,6 @@ const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
const promotionStore = usePromotionStore()
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
@@ -83,13 +81,12 @@ function isWidgetShownOnParents(
? widget.sourceNodeId
: String(widgetNode.id)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
sourceWidgetName: widget.sourceWidgetName
})
}
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})

View File

@@ -14,13 +14,17 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputAtIndex
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgets } from '../shared'
@@ -33,7 +37,6 @@ const { node } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
@@ -55,9 +58,31 @@ const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
const promotionEntries = computed(() =>
promotionStore.getPromotions(node.rootGraph.id, node.id)
)
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
watch(
focusedSection,
@@ -81,37 +106,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
const entries = promotionEntries.value
const { widgets = [] } = node
const result: NodeWidgetsList = []
for (const {
sourceNodeId: entryNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
} of entries) {
const widget = widgets.find((w) => {
if (isPromotedWidgetView(w)) {
if (
String(w.sourceNodeId) !== entryNodeId ||
w.sourceWidgetName !== sourceWidgetName
)
return false
if (!disambiguatingSourceNodeId) return true
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
}
}
return result
return getPromotedWidgets().map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {
@@ -126,12 +121,9 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
!isWidgetPromotedOnSubgraphNode(node, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: getWidgetName(widget)
})
)
})
@@ -190,12 +182,7 @@ function setDraggableState() {
this.draggableItem as HTMLElement
)
promotionStore.movePromotion(
node.rootGraph.id,
node.id,
oldPosition,
newPosition
)
reorderSubgraphInputAtIndex(node, oldPosition, newPosition)
canvasStore.canvas?.setDirty(true, true)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,324 @@
import userEvent from '@testing-library/user-event'
import { render, screen, within } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { graphToPrompt } from '@/utils/executionUtil'
import {
getSourceNodeId,
promoteValueWidgetViaSubgraphInput
} from '@/core/graph/subgraph/promotionUtils'
import SubgraphEditor from './SubgraphEditor.vue'
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subgraphStore: {
shown: 'Shown',
hidden: 'Hidden',
hideAll: 'Hide all',
showAll: 'Show all',
addRecommended: 'Add recommended'
},
rightSidePanel: {
noneSearchDesc: 'No results'
},
g: {
search: 'Search',
searchPlaceholder: 'Search'
}
}
}
})
describe('SubgraphEditor', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('renders preview exposures after promoted inputs without drag handles', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(firstNode)
subgraph.add(secondNode)
subgraph.add(previewNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
usePreviewExposureStore().addExposure(
subgraph.rootGraph.id,
String(host.id),
{
sourceNodeId: String(previewNode.id),
sourcePreviewName: '$$canvas-image-preview'
}
)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second', '$$canvas-image-preview'])
expect(
within(screen.getByTestId('draggable-list'))
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
expect(
within(shown).getAllByTestId('subgraph-widget-drag-handle')
).toHaveLength(2)
})
it('reorders node widgets from dragged promoted input order when widget names repeat', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('seed', 'STRING')
const firstWidget = firstNode.addWidget('text', 'seed', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('seed', 'STRING')
const secondWidget = secondNode.addWidget('text', 'seed', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<button
data-testid="reverse-promoted-widgets"
@click="$emit('update:modelValue', [...modelValue].reverse())"
>
<slot drag-class="draggable-item" />
</button>
`
}
}
}
})
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
String(firstNode.id),
String(secondNode.id)
])
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
})
it('rerenders promoted widgets in dragged input order', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<button
data-testid="reverse-promoted-widgets"
@click="$emit('update:modelValue', [...modelValue].reverse())"
>
<slot drag-class="draggable-item" />
</button>
`
}
}
}
})
expect(
within(screen.getByTestId('subgraph-editor-shown-section'))
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
expect(
within(screen.getByTestId('subgraph-editor-shown-section'))
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['second', 'first'])
})
it('serializes promoted widget values in dragged input order', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<button
data-testid="reverse-promoted-widgets"
@click="$emit('update:modelValue', [...modelValue].reverse())"
>
<slot drag-class="draggable-item" />
</button>
`
}
}
}
})
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
it('sends dragged text input values to the matching prompt targets', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
const graph = host.graph!
graph.add(host)
const firstNode = new LGraphNode('FirstNode')
firstNode.comfyClass = 'FirstNode'
const secondNode = new LGraphNode('SecondNode')
secondNode.comfyClass = 'SecondNode'
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('text', 'STRING')
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('text', 'STRING')
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
props: ['modelValue'],
emits: ['update:modelValue'],
template: `
<button
data-testid="reverse-promoted-widgets"
@click="$emit('update:modelValue', [...modelValue].reverse())"
>
<slot drag-class="draggable-item" />
</button>
`
}
}
}
})
await userEvent.click(screen.getByTestId('reverse-promoted-widgets'))
const { output } = await graphToPrompt(graph)
expect(output[`${host.id}:${firstNode.id}`].inputs.text).toBe('first value')
expect(output[`${host.id}:${secondNode.id}`].inputs.text).toBe(
'second value'
)
})
})

View File

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

View File

@@ -61,6 +61,7 @@ const icon = computed(() =>
</Button>
<div
v-if="isDraggable"
data-testid="subgraph-widget-drag-handle"
class="pointer-events-none icon-[lucide--grip-vertical] size-4"
/>
</div>

View File

@@ -16,7 +16,6 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('Node Reactivity', () => {
@@ -207,7 +206,6 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
'10',
'prompt',
'value',
undefined,
'value'
)
@@ -403,37 +401,6 @@ describe('Subgraph output slot label reactivity', () => {
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview'
})
const { vueNodeData } = useGraphNodeManager(graph)
const vueNode = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = vueNode?.widgets?.find(
(widget) => widget.name === '$$canvas-image-preview'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.options?.canvasOnly).toBe(true)
})
})
describe('Nested promoted widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -476,118 +443,6 @@ describe('Nested promoted widget mapping', () => {
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget(
'text',
'string_a',
'independent',
() => undefined,
{}
)
subgraph.add(independentNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'string_a'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${subgraph.id}:${linkedNode.id}`,
`${subgraph.id}:${independentNode.id}`
])
)
})
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
const innerSubgraph = createTestSubgraph()
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph()
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
const graph = outerSubgraphNode.graph as LGraph
graph.add(outerSubgraphNode)
usePromotionStore().setPromotions(
innerSubgraphNode.rootGraph.id,
innerSubgraphNode.id,
[
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
]
)
usePromotionStore().setPromotions(
outerSubgraphNode.rootGraph.id,
outerSubgraphNode.id,
[
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(firstTextNode.id)
},
{
sourceNodeId: String(innerSubgraphNode.id),
sourceWidgetName: 'text',
disambiguatingSourceNodeId: String(secondTextNode.id)
}
]
)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'text'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
])
)
})
})
describe('Promoted widget sourceExecutionId', () => {

View File

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

View File

@@ -9,19 +9,27 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
| 'nodeOutputs'
| 'nodeOutputsByExecutionId'
| 'nodePreviewImages'
| 'nodePreviewImagesByExecutionId'
| 'getNodeImageUrls'
| 'getNodeImageUrlsByExecutionId'
>
const getNodeImageUrls = vi.hoisted(() =>
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
)
const getNodeImageUrlsByExecutionId = vi.hoisted(() =>
vi.fn<MockNodeOutputStore['getNodeImageUrlsByExecutionId']>()
)
const useNodeOutputStoreMock = vi.hoisted(() =>
vi.fn<() => MockNodeOutputStore>()
)
@@ -35,8 +43,15 @@ vi.mock('@/stores/nodeOutputStore', () => {
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodeOutputsByExecutionId: reactive<
MockNodeOutputStore['nodeOutputsByExecutionId']
>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
nodePreviewImagesByExecutionId: reactive<
MockNodeOutputStore['nodePreviewImagesByExecutionId']
>({}),
getNodeImageUrls,
getNodeImageUrlsByExecutionId
}
}
@@ -83,6 +98,18 @@ function seedPreviewImages(
}
}
function exposePreview(
setup: ReturnType<typeof createSetup>,
sourceNodeId: string,
sourcePreviewName = '$$canvas-image-preview'
) {
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
createNodeLocatorId(setup.subgraphNode.rootGraph.id, setup.subgraphNode.id),
{ sourceNodeId, sourcePreviewName }
)
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
@@ -90,6 +117,7 @@ describe(usePromotedPreviews, () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
getNodeImageUrls.mockReset()
getNodeImageUrlsByExecutionId.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
@@ -109,11 +137,6 @@ describe(usePromotedPreviews, () => {
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -122,11 +145,7 @@ describe(usePromotedPreviews, () => {
it('returns image preview for promoted $$ widget with outputs', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
@@ -146,11 +165,7 @@ describe(usePromotedPreviews, () => {
it('returns video type when interior node has video previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
@@ -162,11 +177,7 @@ describe(usePromotedPreviews, () => {
it('returns audio type when interior node has audio previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
@@ -185,16 +196,8 @@ describe(usePromotedPreviews, () => {
id: 20,
previewMediaType: 'image'
})
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
exposePreview(setup, '20')
seedOutputs(setup.subgraph.id, [10, 20])
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
@@ -212,11 +215,7 @@ describe(usePromotedPreviews, () => {
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
@@ -236,11 +235,7 @@ describe(usePromotedPreviews, () => {
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -262,11 +257,7 @@ describe(usePromotedPreviews, () => {
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -274,29 +265,16 @@ describe(usePromotedPreviews, () => {
it('skips missing interior nodes', () => {
const setup = createSetup()
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '99')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('ignores non-$$ promoted widgets', () => {
it('uses preview exposures by source preview name', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
exposePreview(setup, '10')
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
@@ -306,4 +284,116 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toHaveLength(1)
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
})
it('renders leaf media exposed through a nested subgraph host', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const store = usePreviewExposureStore()
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(innerHost.id),
{
sourceNodeId: String(leafNode.id),
sourcePreviewName: '$$canvas-image-preview'
}
)
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
createNodeLocatorId(
outerSetup.subgraphNode.rootGraph.id,
outerSetup.subgraphNode.id
),
{
sourceNodeId: String(innerHost.id),
sourcePreviewName: '$$canvas-image-preview'
}
)
const mockUrls = ['/view?filename=leaf.png']
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
getNodeImageUrls.mockImplementation((node: LGraphNode) =>
node === leafNode ? mockUrls : []
)
const { promotedPreviews } = usePromotedPreviews(
() => outerSetup.subgraphNode
)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: mockUrls
}
])
})
it('keeps promoted previews distinct for multiple instances of a shared subgraph definition', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
const store = usePreviewExposureStore()
store.addExposure(firstHost.rootGraph.id, '11', {
sourceNodeId: String(innerHost.id),
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(firstHost.rootGraph.id, '12', {
sourceNodeId: String(innerHost.id),
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(firstHost.rootGraph.id, '11:20', {
sourceNodeId: String(leafNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(firstHost.rootGraph.id, '12:20', {
sourceNodeId: String(leafNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
nodeOutputStore.nodePreviewImagesByExecutionId['11:20:10'] = ['blob:first']
nodeOutputStore.nodePreviewImagesByExecutionId['12:20:10'] = ['blob:second']
getNodeImageUrlsByExecutionId.mockImplementation((executionId) => {
if (executionId === '11:20:10') return ['blob:first']
if (executionId === '12:20:10') return ['blob:second']
return undefined
})
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
[
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: ['blob:first']
}
]
)
expect(
usePromotedPreviews(() => secondHost).promotedPreviews.value
).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: ['blob:second']
}
])
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,188 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
import { readHostQuarantine } from '@/core/graph/subgraph/migration/quarantineEntry'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const graph = hostNode.graph!
graph.add(hostNode)
return hostNode
}
describe(flushProxyWidgetMigration, () => {
it('returns an empty result when no proxyWidgets are present', () => {
const host = buildHost()
const result = flushProxyWidgetMigration({ hostNode: host })
expect(result).toEqual({
repaired: 0,
primitiveRepaired: 0,
previewMigrated: 0,
quarantined: 0
})
})
it('migrates a preview-shaped entry into the PreviewExposureStore', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), '$$canvas-image-preview']
]
const result = flushProxyWidgetMigration({ hostNode: host })
expect(result.previewMigrated).toBe(1)
expect(result.quarantined).toBe(0)
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
})
it('quarantines entries whose source node has disappeared', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
const result = flushProxyWidgetMigration({ hostNode: host })
expect(result.quarantined).toBe(1)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'seed'],
reason: 'missingSourceNode'
})
])
})
it('counts already-linked entries as repaired and applies the host value', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('number', 'seed', 0, () => {})
host.subgraph.add(innerNode)
const inputSlot = host.addInput('seed_link', '*')
let widgetValue: TWidgetValue = 0
inputSlot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: 'seed',
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
}
})
host.properties.proxyWidgets = [[String(innerNode.id), 'seed']]
const result = flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(result.repaired).toBe(1)
expect(result.quarantined).toBe(0)
expect(widgetValue).toBe(99)
})
it('clears properties.proxyWidgets after a successful flush', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgets).toBeUndefined()
})
describe('idempotency', () => {
it('re-running flush over a fully migrated host produces no further mutations', () => {
const host = buildHost()
const innerNode = new LGraphNode('Inner')
innerNode.addWidget('text', '$$canvas-image-preview', '', () => {})
host.subgraph.add(innerNode)
host.properties.proxyWidgets = [
[String(innerNode.id), '$$canvas-image-preview']
]
const first = flushProxyWidgetMigration({ hostNode: host })
expect(first.previewMigrated).toBe(1)
const exposuresAfterFirst = usePreviewExposureStore()
.getExposures(host.rootGraph.id, String(host.id))
.map((e) => ({ ...e }))
const second = flushProxyWidgetMigration({ hostNode: host })
expect(second).toEqual({
repaired: 0,
primitiveRepaired: 0,
previewMigrated: 0,
quarantined: 0
})
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
).toEqual(exposuresAfterFirst)
})
it('re-running flush over a quarantined host does not duplicate quarantine entries', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
const firstQuarantine = readHostQuarantine(host)
expect(firstQuarantine).toHaveLength(1)
// Reseed proxyWidgets to simulate a stale legacy reload of the same
// unresolved entry; flush must still produce no duplicates.
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -9,7 +9,12 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
@@ -19,11 +24,16 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
getSourceNodeId,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected
pruneDisconnected,
reorderSubgraphInputAtIndex,
reorderSubgraphInputsByName,
reorderSubgraphInputsByWidgetOrder
} from './promotionUtils'
function widget(
@@ -112,58 +122,64 @@ describe('pruneDisconnected', () => {
vi.restoreAllMocks()
})
it('removes disconnected entries and emits a dev warning', () => {
it('removes disconnected linked inputs and emits a dev warning', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraphNode.subgraph.add(interiorNode)
interiorNode.addWidget('text', 'kept', 'value', () => {})
const keptInput = interiorNode.addInput('kept', 'STRING')
const keptWidget = interiorNode.addWidget('text', 'kept', 'value', () => {})
keptInput.widget = { name: keptWidget.name }
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
},
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
])
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
})
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: '9999',
sourceWidgetName: 'missing-node'
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(subgraph.inputs.map((input) => input.name)).toEqual(['kept'])
expect(warnSpy).toHaveBeenCalledOnce()
})
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
it('does not prune preview exposures for PreviewImage nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('PreviewImage')
interiorNode.type = 'PreviewImage'
subgraphNode.subgraph.add(interiorNode)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
const hostLocator = String(subgraphNode.id)
usePreviewExposureStore().addExposure(
subgraphNode.rootGraph.id,
hostLocator,
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
)
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -232,6 +248,50 @@ describe('promoteRecommendedWidgets', () => {
updatePreviewsMock.mockReset()
})
it('promotes recommended value widgets through linked subgraph inputs', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Sampler')
const input = interiorNode.addInput('seed', 'INT')
const seedWidget = interiorNode.addWidget('number', 'seed', 123, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(interiorNode)
promoteRecommendedWidgets(subgraphNode)
const linkedInput = subgraph.inputs.find((slot) => slot.name === 'seed')
expect(linkedInput).toBeDefined()
expect(input.link).not.toBeNull()
expect(linkedInput?.linkIds).toContain(input.link)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const hostLocator = String(subgraphNode.id)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
expect(subgraph.inputs).toHaveLength(0)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('skips deferred updatePreviews when a preview widget already exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -252,7 +312,7 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
it('eagerly exposes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
@@ -261,17 +321,21 @@ describe('promoteRecommendedWidgets', () => {
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
const hostLocator = String(subgraphNode.id)
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toContainEqual({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
it('hydrates $$canvas-image-preview exposure on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
@@ -284,13 +348,17 @@ describe('promoteRecommendedWidgets', () => {
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
const store = usePromotionStore()
const hostLocator = String(subgraphNode.id)
expect(
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toContainEqual({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
})
})
@@ -314,12 +382,11 @@ describe('hasUnpromotedWidgets', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
const input = interiorNode.addInput('seed', 'STRING')
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
input.widget = { name: widget.name }
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
subgraph.addInput('seed', 'STRING').connect(input, interiorNode)
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
@@ -416,3 +483,193 @@ describe('isLinkedPromotion', () => {
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
})
})
describe('reorderSubgraphInputsByName', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('reorders subgraph inputs and host inputs by subgraph input name', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'number' },
{ name: 'second', type: 'number' },
{ name: 'third', type: 'number' }
]
})
const host = createTestSubgraphNode(subgraph)
reorderSubgraphInputsByName(host, ['third', 'first', 'second'])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'third',
'first',
'second'
])
expect(host.inputs.map((input) => input.name)).toEqual([
'third',
'first',
'second'
])
})
it('reorders promoted widgets on the host node from subgraph input order', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
expect(host.widgets.map((widget) => widget.name)).toEqual([
'first',
'second'
])
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
})
it('updates subgraph input link slot indices after reordering', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
reorderSubgraphInputsByName(host, ['second', 'first'])
const [secondSlot, firstSlot] = subgraph.inputs
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
expect(secondLink?.origin_slot).toBe(0)
expect(firstLink?.origin_slot).toBe(1)
})
})
describe('reorderSubgraphInputAtIndex', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('moves host widget values with dragged input rows', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('text', 'STRING')
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('text', 'STRING')
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputAtIndex(host, 0, 1)
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(host.widgets.map((widget) => widget.value)).toEqual([
'second value',
'first value'
])
})
it('updates subgraph link slot indices after moving a row', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
reorderSubgraphInputAtIndex(host, 0, 1)
const [secondSlot, firstSlot] = subgraph.inputs
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
expect(secondLink?.origin_slot).toBe(0)
expect(firstLink?.origin_slot).toBe(1)
})
})
describe('reorderSubgraphInputsByWidgetOrder', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('reorders duplicate-named promoted inputs by widget identity', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('text', 'STRING')
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('text', 'STRING')
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
expect(host.widgets.map((widget) => getSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
})

View File

@@ -8,6 +8,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
@@ -15,12 +16,12 @@ import {
} from '@/composables/node/canvasImagePreviewTypes'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export type WidgetItem = [LGraphNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
@@ -37,7 +38,20 @@ export function isLinkedPromotion(
sourceNodeId: string,
sourceWidgetName: string
): boolean {
return subgraphNode.inputs.some((input) => {
return (
findHostInputForPromotion(subgraphNode, sourceNodeId, sourceWidgetName) !==
undefined
)
}
/** Find the host input on `subgraphNode` whose `_widget` is the
* `PromotedWidgetView` for `(sourceNodeId, sourceWidgetName)`. */
function findHostInputForPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
const w = input._widget
return (
w &&
@@ -48,9 +62,157 @@ export function isLinkedPromotion(
})
}
export function reorderSubgraphInputsByName(
subgraphNode: SubgraphNode,
orderedInputNames: readonly string[]
): void {
const order = new Map(
orderedInputNames.map((name, index) => [name, index] as const)
)
const byOrder = <T extends { name: string }>(left: T, right: T) => {
const leftOrder = order.get(left.name) ?? Number.MAX_SAFE_INTEGER
const rightOrder = order.get(right.name) ?? Number.MAX_SAFE_INTEGER
return leftOrder - rightOrder
}
const orderedIndices = subgraphNode.subgraph.inputs
.map((input, index) => ({ input, index }))
.sort((left, right) => byOrder(left.input, right.input))
.map(({ index }) => index)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
export function reorderSubgraphInputsByWidgetOrder(
subgraphNode: SubgraphNode,
orderedWidgets: readonly IBaseWidget[]
): void {
const remainingIndices = new Set(subgraphNode.inputs.keys())
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
for (const index of remainingIndices) {
const widget = subgraphNode.inputs[index]?._widget
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
remainingIndices.delete(index)
return [index]
}
}
return []
})
for (const index of remainingIndices) orderedIndices.push(index)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
export function reorderSubgraphInputAtIndex(
subgraphNode: SubgraphNode,
oldPosition: number,
newPosition: number
): void {
if (
oldPosition < 0 ||
newPosition < 0 ||
oldPosition >= subgraphNode.subgraph.inputs.length ||
newPosition >= subgraphNode.subgraph.inputs.length
)
return
const orderedIndices = subgraphNode.subgraph.inputs.map((_, index) => index)
const [movedIndex] = orderedIndices.splice(oldPosition, 1)
if (movedIndex !== undefined)
orderedIndices.splice(newPosition, 0, movedIndex)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const rows = subgraphNode.subgraph.inputs.map((input, index) => ({
subgraphInput: input,
hostInput: subgraphNode.inputs[index],
value: subgraphNode.inputs[index]?._widget?.value
}))
const orderedRows = orderedIndices.flatMap((index) => rows[index] ?? [])
subgraphNode.subgraph.inputs.splice(
0,
subgraphNode.subgraph.inputs.length,
...orderedRows.map((row) => row.subgraphInput)
)
subgraphNode.inputs.splice(
0,
subgraphNode.inputs.length,
...orderedRows.flatMap((row) => row.hostInput ?? [])
)
for (const [index, input] of subgraphNode.subgraph.inputs.entries()) {
for (const linkId of input.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (link) link.origin_slot = index
}
}
subgraphNode.widgets.forEach((widget, index) => {
const value = orderedRows[index]?.value
if (value !== undefined) widget.value = value
})
}
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
return w.sourceNodeId
}
function isPreviewExposed(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
const hostLocator = String(subgraphNode.id)
return usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === source.sourceNodeId &&
exposure.sourcePreviewName === source.sourceWidgetName
)
}
function isPromotedOnParent(
subgraphNode: SubgraphNode,
widget: IBaseWidget,
source: PromotedWidgetSource
): boolean {
if (isPreviewPseudoWidget(widget))
return isPreviewExposed(subgraphNode, source)
return isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
)
}
export function isWidgetPromotedOnSubgraphNode(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
return (
isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
) || isPreviewExposed(subgraphNode, source)
)
}
function toPromotionSource(
@@ -59,21 +221,75 @@ function toPromotionSource(
): PromotedWidgetSource {
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
sourceWidgetName: getWidgetName(widget)
}
}
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
for (const parent of parents) {
parent.computeSize(parent.size)
parent.setDirtyCanvas(true, true)
parent.setDirtyCanvas?.(true, true)
}
useCanvasStore().canvas?.setDirty(true, true)
}
type CanonicalPromotionResult =
| { ok: true }
| { ok: false; reason: 'missingSourceSlot' | 'connectFailed' }
export function promoteValueWidgetViaSubgraphInput(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourceWidget: IBaseWidget
): CanonicalPromotionResult {
const sourceWidgetName = getWidgetName(sourceWidget)
if (
isLinkedPromotion(subgraphNode, String(sourceNode.id), sourceWidgetName)
) {
return { ok: true }
}
const sourceSlot = sourceNode.getSlotFromWidget(sourceWidget)
if (!sourceSlot) return { ok: false, reason: 'missingSourceSlot' }
const existingNames = subgraphNode.subgraph.inputs.map((input) => input.name)
const inputName = nextUniqueName(sourceWidgetName, existingNames)
const subgraphInput = subgraphNode.subgraph.addInput(
inputName,
String(sourceSlot.type ?? sourceWidget.type ?? '*')
)
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) {
subgraphNode.subgraph.removeInput(subgraphInput)
return { ok: false, reason: 'connectFailed' }
}
return { ok: true }
}
function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourcePreviewName: string
): void {
const store = usePreviewExposureStore()
const rootGraphId = subgraphNode.rootGraph.id
const hostLocator = String(subgraphNode.id)
const existing = store
.getExposures(rootGraphId, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === String(sourceNode.id) &&
exposure.sourcePreviewName === sourcePreviewName
)
if (existing) return
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: String(sourceNode.id),
sourcePreviewName
})
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
@@ -98,10 +314,19 @@ export function promoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, source)
if (isPreviewPseudoWidget(widget)) {
promotePreviewViaExposure(
parent,
node as LGraphNode,
source.sourceWidgetName
)
continue
}
if ('getSlotFromWidget' in node) {
promoteValueWidgetViaSubgraphInput(parent, node as LGraphNode, widget)
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -116,10 +341,40 @@ export function demoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, source)
if (!parent.subgraph) continue
const hostInput = findHostInputForPromotion(
parent,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (linkedInput) {
parent.subgraph.removeInput(linkedInput)
continue
}
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
const hostLocator = String(parent.id)
const exposure = previewStore
.getExposures(parent.rootGraph.id, hostLocator)
.find(
(entry) =>
entry.sourceNodeId === source.sourceNodeId &&
entry.sourcePreviewName === source.sourceWidgetName
)
if (exposure) {
previewStore.removeExposure(
parent.rootGraph.id,
hostLocator,
exposure.name
)
continue
}
}
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -152,11 +407,10 @@ export function addWidgetPromotionOptions(
widget: IBaseWidget,
node: LGraphNode
) {
const store = usePromotionStore()
const parents = getParentNodes()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
(parent) => !isPromotedOnParent(parent, widget, source)
)
if (promotableParents.length > 0)
options.unshift({
@@ -189,10 +443,9 @@ export function tryToggleWidgetPromotion() {
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
(parent) => !isPromotedOnParent(parent, widget, source)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -248,7 +501,6 @@ function nodeWidgets(n: LGraphNode): WidgetItem[] {
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
@@ -260,14 +512,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
function promotePreviewWidget() {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
promotePreviewViaExposure(subgraphNode, node, widget.name)
}
// Promote preview widgets that already exist (e.g. custom node DOM widgets
// like VHS videopreview that are created in onNodeCreated).
@@ -282,19 +527,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
canvasSource
)
) {
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
promotePreviewViaExposure(subgraphNode, node, CANVAS_IMAGE_PREVIEW_WIDGET)
continue
}
@@ -305,43 +538,42 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
.filter(([, widget]) => !isPreviewPseudoWidget(widget))
for (const [n, w] of filteredWidgets) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
toPromotionSource(n, w)
)
promoteValueWidgetViaSubgraphInput(subgraphNode, n, w)
}
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const subgraph = subgraphNode.subgraph
const entries = store.getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: PromotedWidgetSource[] = []
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.sourceNodeId)
const staleInputs = subgraph.inputs.filter((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
const node = subgraph.getNodeById(widget.sourceNodeId)
if (!node) {
removedEntries.push(entry)
return false
removedEntries.push(widget)
return true
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.sourceWidgetName
(iw) => iw.name === widget.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(entry)
removedEntries.push(widget)
}
return hasWidget
return !hasWidget
})
for (const input of staleInputs) {
subgraph.removeInput(input)
}
if (removedEntries.length > 0 && import.meta.env.DEV) {
console.warn(
'[proxyWidgetUtils] Pruned disconnected promotions',
'[subgraphInputs] Pruned disconnected promoted widget inputs',
removedEntries,
{
graphId: subgraphNode.rootGraph.id,
@@ -350,24 +582,22 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
)
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
message: `Pruned ${removedEntries.length} disconnected promoted widget input(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
const { subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
getPromotableWidgets(interiorNode).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
!isPromotedOnParent(subgraphNode, widget, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})

View File

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

View File

@@ -20,8 +20,7 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
@@ -29,7 +28,6 @@ function traversePromotedWidgetChain(
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
let currentSourceNodeId = sourceNodeId
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
@@ -39,7 +37,7 @@ function traversePromotedWidgetChain(
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
@@ -52,8 +50,7 @@ function traversePromotedWidgetChain(
const sourceWidget = findWidgetByIdentity(
sourceNode.widgets,
currentWidgetName,
currentSourceNodeId
currentWidgetName
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
@@ -73,7 +70,6 @@ function traversePromotedWidgetChain(
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
currentSourceNodeId = undefined
}
return { status: 'failure', failure: 'max-depth-exceeded' }
@@ -81,34 +77,20 @@ function traversePromotedWidgetChain(
function findWidgetByIdentity(
widgets: IBaseWidget[] | undefined,
widgetName: string,
sourceNodeId?: string
widgetName: string
): IBaseWidget | undefined {
if (!widgets) return undefined
if (sourceNodeId) {
return widgets.find(
(entry) =>
isPromotedWidgetView(entry) &&
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
sourceNodeId &&
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
)
}
return widgets.find((entry) => entry.name === widgetName)
return widgets?.find((entry) => entry.name === widgetName)
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
const widget = findWidgetByIdentity(node.widgets, widgetName)
if (!widget) return undefined
return { node, widget }
@@ -117,11 +99,10 @@ export function resolvePromotedWidgetAtHost(
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string,
sourceNodeId?: string
widgetName: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}

View File

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

View File

@@ -1,12 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
widgetName: string
sourceNodeId?: string
}
export function resolveSubgraphInputTarget(
@@ -17,29 +15,18 @@ export function resolveSubgraphInputTarget(
node,
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (inputNode.isSubgraphNode()) {
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (isPromotedWidgetView(targetWidget)) {
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.sourceWidgetName,
sourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
// ADR 0009: each SubgraphNode is opaque. The promoted target is the
// child SubgraphNode's input slot, not a deeper leaf widget.
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name
}
}
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.name

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
export const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourcePreviewName: z.string()
})
export type PreviewExposure = z.infer<typeof previewExposureSchema>
const previewExposuresPropertySchema = z.array(previewExposureSchema)
export function parsePreviewExposures(
property: NodeProperty | undefined
): PreviewExposure[] {
if (property === undefined) return []
try {
if (typeof property === 'string') property = JSON.parse(property)
const result = previewExposuresPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(
`Invalid assignment for properties.previewExposures:\n${error}`
)
} catch (e) {
console.warn('Failed to parse properties.previewExposures:', e)
}
return []
}

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
export const proxyWidgetQuarantineReasonSchema = z.enum([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
])
export type ProxyWidgetQuarantineReason = z.infer<
typeof proxyWidgetQuarantineReasonSchema
>
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
originalEntry: serializedProxyWidgetTupleSchema,
reason: proxyWidgetQuarantineReasonSchema,
hostValue: z.unknown().optional(),
attemptedAtVersion: z.literal(1)
})
const proxyWidgetErrorQuarantinePropertySchema = z.array(
proxyWidgetErrorQuarantineEntrySchema
)
export type ProxyWidgetErrorQuarantineEntry = Omit<
z.infer<typeof proxyWidgetErrorQuarantineEntrySchema>,
'hostValue'
> & { hostValue?: TWidgetValue }
export function parseProxyWidgetErrorQuarantine(
property: NodeProperty | undefined
): ProxyWidgetErrorQuarantineEntry[] {
if (property === undefined) return []
try {
const result = proxyWidgetErrorQuarantinePropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data as ProxyWidgetErrorQuarantineEntry[]
const error = fromZodError(result.error)
console.warn(
`Invalid assignment for properties.proxyWidgetErrorQuarantine:\n${error}`
)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgetErrorQuarantine:', e)
}
return []
}

View File

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

View File

@@ -13,7 +13,7 @@ import {
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestSubgraphData,
@@ -280,17 +280,17 @@ describe('Graph Clearing and Callbacks', () => {
expect(graph.nodes.length).toBe(0)
})
test('clear() removes graph-scoped promotion and widget-value state', () => {
test('clear() removes graph-scoped preview and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
const graphId = 'graph-clear-cleanup' as UUID
graph.id = graphId
const promotionStore = usePromotionStore()
promotionStore.promote(graphId, 1 as NodeId, {
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(graphId, `${graphId}:1`, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
sourcePreviewName: '$$canvas-image-preview'
})
const widgetValueStore = useWidgetValueStore()
@@ -305,27 +305,21 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
previewExposureStore.getExposures(graphId, `${graphId}:1`)
).toHaveLength(1)
graph.clear()
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)
})
})

View File

@@ -1,6 +1,5 @@
import { toString } from 'es-toolkit/compat'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
@@ -10,10 +9,7 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -54,6 +50,7 @@ import type {
Size
} from './interfaces'
import { LiteGraph, SubgraphNode } from './litegraph'
import { runSubgraphMigrationFlushHook } from './subgraph/subgraphMigrationHook'
import {
alignOutsideContainer,
alignToContainer,
@@ -379,7 +376,7 @@ export class LGraph
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
usePromotionStore().clearGraph(graphId)
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}
@@ -1927,13 +1924,6 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
// Repair ancestor promotions: when nodes are packed into a nested
// subgraph, any host SubgraphNode whose proxyWidgets referenced the
// moved nodes must be repointed to chain through the new nested node.
if (!this.isRootGraph) {
this._repointAncestorPromotions(nodes, subgraphNode as SubgraphNode)
}
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
@@ -1946,75 +1936,6 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
/**
* After packing nodes into a nested subgraph, repoint any ancestor
* SubgraphNode promotions that referenced the moved nodes so they
* chain through the newly created nested SubgraphNode.
*/
private _repointAncestorPromotions(
movedNodes: Set<LGraphNode>,
nestedSubgraphNode: SubgraphNode
): void {
const movedNodeIds = new Set([...movedNodes].map((n) => String(n.id)))
const store = usePromotionStore()
const nestedNodeId = String(nestedSubgraphNode.id)
const graphId = this.rootGraph.id
const nestedEntries = store.getPromotions(graphId, nestedSubgraphNode.id)
const nextNestedEntries = [...nestedEntries]
const nestedEntryKeys = new Set(
nestedEntries.map((entry) => makePromotionEntryKey(entry))
)
const hostUpdates: Array<{
node: SubgraphNode
entries: PromotedWidgetSource[]
}> = []
// Find all SubgraphNode instances that host `this` subgraph.
// They live in any graph and have `type === this.id`.
const allGraphs: LGraph[] = [
this.rootGraph,
...this.rootGraph._subgraphs.values()
]
for (const graph of allGraphs) {
for (const node of graph._nodes) {
if (!node.isSubgraphNode() || node.type !== this.id) continue
const entries = store.getPromotions(graphId, node.id)
const movedEntries = entries.filter((entry) =>
movedNodeIds.has(entry.sourceNodeId)
)
if (movedEntries.length === 0) continue
for (const entry of movedEntries) {
const key = makePromotionEntryKey(entry)
if (nestedEntryKeys.has(key)) continue
nestedEntryKeys.add(key)
nextNestedEntries.push(entry)
}
const nextEntries = entries.map((entry) => {
if (!movedNodeIds.has(entry.sourceNodeId)) return entry
return {
sourceNodeId: nestedNodeId,
sourceWidgetName: entry.sourceWidgetName,
disambiguatingSourceNodeId:
entry.disambiguatingSourceNodeId ?? entry.sourceNodeId
}
})
hostUpdates.push({ node, entries: nextEntries })
}
}
if (nextNestedEntries.length !== nestedEntries.length)
store.setPromotions(graphId, nestedSubgraphNode.id, nextNestedEntries)
for (const { node, entries } of hostUpdates) {
store.setPromotions(graphId, node.id, entries)
node.rebuildInputWidgetBindings()
}
}
unpackSubgraph(
subgraphNode: SubgraphNode,
options?: { skipMissingNodes?: boolean }
@@ -2735,6 +2656,16 @@ export class LGraph
this.updateExecutionOrder()
// ADR 0009: forward-ratchet legacy properties.proxyWidgets on each
// host SubgraphNode. Late-bound hook (registered in app init) so the
// LGraph layer doesn't pull in the PreviewExposureStore at module
// load — that would create a circular dependency.
for (const node of this._nodes) {
if (!(node instanceof SubgraphNode)) continue
if (node.properties?.proxyWidgets === undefined) continue
runSubgraphMigrationFlushHook(node, nodeDataMap.get(node.id))
}
this.onConfigure?.(data)
this.incrementVersion()

View File

@@ -9065,9 +9065,9 @@ function remapProxyWidgets(
/**
* Remaps pasted subgraph interior node IDs that would collide with existing
* node IDs in the root graph. Also patches subgraph link node IDs and
* SubgraphNode `properties.proxyWidgets` references so promoted widget
* associations stay aligned with remapped interior IDs.
* node IDs in the root graph. Also patches subgraph link node IDs and legacy
* SubgraphNode `properties.proxyWidgets` references so migration input stays
* aligned with remapped interior IDs until the ADR 0009 flush consumes it.
*/
export function remapClipboardSubgraphNodeIds(
parsed: ClipboardItems,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,760 @@
/**
* Tests for SubgraphNode.serialize() after ADR 0009.
*
* Covers:
* - Removed copy-back loop: exterior promoted host value does NOT mutate
* the corresponding interior widget value.
* - properties.proxyWidgets is no longer re-emitted on serialize.
* - properties.previewExposures round-trip through the
* PreviewExposureStore.
* - properties.proxyWidgetErrorQuarantine round-trips and is inert at
* runtime; an empty quarantine is omitted from the serialized payload.
*/
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
appendHostQuarantine,
makeQuarantineEntry
} from '@/core/graph/subgraph/migration/quarantineEntry'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import type { ISlotType, TWidgetType } from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import {
reorderSubgraphInputAtIndex,
reorderSubgraphInputsByName
} from '@/core/graph/subgraph/promotionUtils'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { graphToPrompt } from '@/utils/executionUtil'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNodeWithWidget(
title: string,
widgetType: TWidgetType = 'number',
widgetValue: unknown = 42,
slotType: ISlotType = 'number'
) {
const node = new LGraphNode(title)
const input = node.addInput('value', slotType)
node.addOutput('out', slotType)
// @ts-expect-error Abstract class instantiation
const widget = new BaseWidget({
name: 'widget',
type: widgetType,
value: widgetValue,
y: 0,
options: widgetType === 'number' ? { min: 0, max: 100, step: 1 } : {},
node
})
node.widgets = [widget]
input.widget = { name: widget.name }
return { node, widget, input }
}
describe('SubgraphNode.serialize (ADR 0009)', () => {
describe('removed copy-back loop', () => {
it('does not call subgraphInput.getConnectedWidgets() during serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode } = createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
// The pre-ADR-0009 copy-back loop iterated input._subgraphSlot
// .getConnectedWidgets() and assigned the host wrapper's value to
// every interior widget. After removal, serialize must not visit
// that path at all, preventing cross-host stomping.
const slot = hostNode.inputs.find((i) => i._subgraphSlot)?._subgraphSlot
expect(slot).toBeDefined()
const spy = vi.spyOn(slot!, 'getConnectedWidgets')
hostNode.serialize()
expect(spy).not.toHaveBeenCalled()
})
})
describe('host widget values', () => {
it('serializes promoted values from each host independently', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode } = createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const firstHost = createTestSubgraphNode(subgraph, { id: 101 })
const secondHost = createTestSubgraphNode(subgraph, { id: 102 })
subgraph.rootGraph.add(firstHost)
subgraph.rootGraph.add(secondHost)
firstHost.widgets[0].value = 111
secondHost.widgets[0].value = 222
expect(firstHost.serialize().widgets_values).toEqual([111])
expect(secondHost.serialize().widgets_values).toEqual([222])
})
it('keeps promoted values attached to their inputs after reordering', () => {
const subgraph = createTestSubgraph()
const first = createNodeWithWidget('First', 'number', 1)
const second = createNodeWithWidget('Second', 'number', 2)
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'number')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'number')
secondInput.connect(second.input, second.node)
const host = createTestSubgraphNode(subgraph)
host.widgets[0].value = 111
host.widgets[1].value = 222
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.serialize().widgets_values).toEqual([222, 111])
})
it('serializes source widget store values after reordering', () => {
const subgraph = createTestSubgraph()
const first = createNodeWithWidget('First', 'text', '', 'STRING')
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'STRING')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'STRING')
secondInput.connect(second.input, second.node)
const host = createTestSubgraphNode(subgraph)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: first.node.id,
name: first.widget.name,
type: first.widget.type,
value: 'first value',
options: {}
})
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: second.node.id,
name: second.widget.name,
type: second.widget.type,
value: 'second value',
options: {}
})
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
it('moves Vue-edited values with promoted widgets after reordering', () => {
const subgraph = createTestSubgraph()
const first = createNodeWithWidget('First', 'text', '', 'STRING')
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'STRING')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'STRING')
secondInput.connect(second.input, second.node)
const host = createTestSubgraphNode(subgraph)
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[0].updateHandler('first value')
widgets[1].updateHandler('second value')
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
it('sends Vue-edited values to dragged promoted widget targets', async () => {
const subgraph = createTestSubgraph()
const first = createNodeWithWidget('First', 'text', '', 'STRING')
const second = createNodeWithWidget('Second', 'text', '', 'STRING')
first.node.comfyClass = 'First'
second.node.comfyClass = 'Second'
subgraph.add(first.node)
subgraph.add(second.node)
const firstInput = subgraph.addInput('first', 'STRING')
firstInput.connect(first.input, first.node)
const secondInput = subgraph.addInput('second', 'STRING')
secondInput.connect(second.input, second.node)
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
host.graph?.add(host)
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[0].updateHandler('first value')
widgets[1].updateHandler('second value')
reorderSubgraphInputsByName(host, ['second', 'first'])
const { output } = await graphToPrompt(host.rootGraph)
expect(output[`${host.id}:${first.node.id}`].inputs.value).toBe(
'first value'
)
expect(output[`${host.id}:${second.node.id}`].inputs.value).toBe(
'second value'
)
})
it('keeps text and seed values on their targets when the seed input moves up', async () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
positive.node.comfyClass = 'Positive'
seed.node.comfyClass = 'Sampler'
negative.node.comfyClass = 'Negative'
subgraph.add(positive.node)
subgraph.add(seed.node)
subgraph.add(negative.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
host.graph?.add(host)
host.widgets[0].value = 'positive prompt'
host.widgets[1].value = 'negative prompt'
host.widgets[2].value = 123456
reorderSubgraphInputAtIndex(host, 2, 1)
const { output } = await graphToPrompt(host.rootGraph)
expect(host.serialize().widgets_values).toEqual([
'positive prompt',
123456,
'negative prompt'
])
expect(output[`${host.id}:${positive.node.id}`].inputs.value).toBe(
'positive prompt'
)
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
expect(output[`${host.id}:${negative.node.id}`].inputs.value).toBe(
'negative prompt'
)
})
it('keeps Vue-edited text and seed values on their targets when the seed input moves up', async () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
positive.node.comfyClass = 'Positive'
negative.node.comfyClass = 'Negative'
seed.node.comfyClass = 'Sampler'
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
host.graph?.add(host)
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[0].updateHandler('positive prompt')
widgets[1].updateHandler('negative prompt')
widgets[2].updateHandler(123456)
reorderSubgraphInputAtIndex(host, 2, 1)
const { output } = await graphToPrompt(host.rootGraph)
expect(host.serialize().widgets_values).toEqual([
'positive prompt',
123456,
'negative prompt'
])
expect(output[`${host.id}:${positive.node.id}`].inputs.value).toBe(
'positive prompt'
)
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
expect(output[`${host.id}:${negative.node.id}`].inputs.value).toBe(
'negative prompt'
)
})
it('ignores direct source seed changes after the seed input moves up', async () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
positive.node.comfyClass = 'Positive'
negative.node.comfyClass = 'Negative'
seed.node.comfyClass = 'Sampler'
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
host.comfyClass = 'Subgraph'
host.graph?.add(host)
host.widgets[0].value = 'positive prompt'
host.widgets[1].value = 'negative prompt'
host.widgets[2].value = 123456
reorderSubgraphInputAtIndex(host, 2, 1)
seed.widget.linkedWidgets = [
{
name: 'control_after_generate',
value: 'increment',
serialize: false,
beforeQueued: () => {},
afterQueued: () => {}
} as never
]
seed.widget.value = 789
const { output } = await graphToPrompt(host.rootGraph)
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(123456)
})
it('syncs Vue-edited promoted seed values to the controlled source widget after moving seed up', async () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
seed.widget.options.max = 1125899906842624
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
seed.widget.linkedWidgets = [
{
name: 'control_after_generate',
value: 'fixed',
serialize: false,
beforeQueued: () => {},
afterQueued: () => {}
} as never
]
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[2].updateHandler(123456)
reorderSubgraphInputAtIndex(host, 2, 1)
expect(seed.widget.value).toBe(123456)
})
it('shows a control-updated promoted seed value in processed widgets after moving seed up', () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
seed.widget.options.max = 1125899906842624
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
const nodeData = extractVueNodeData(host)
const widgets = computeProcessedWidgets({
nodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
widgets[2].updateHandler(123456)
seed.widget.linkedWidgets = [
{
name: 'control_after_generate',
value: 'increment',
serialize: false,
beforeQueued: () => {},
afterQueued: () => {},
[IS_CONTROL_WIDGET]: true
} as never
]
reorderSubgraphInputAtIndex(host, 2, 1)
host.widgets[1].afterQueued?.()
const updatedNodeData = extractVueNodeData(host)
const updatedWidgets = computeProcessedWidgets({
nodeData: updatedNodeData,
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: {
getTooltipConfig: () => ({}),
handleNodeRightClick: () => {}
}
})
expect(updatedWidgets[1].value).toBe(123457)
})
it('increments the promoted host seed without using the source seed value', () => {
const subgraph = createTestSubgraph()
const positive = createNodeWithWidget('Positive', 'text', '', 'STRING')
const negative = createNodeWithWidget('Negative', 'text', '', 'STRING')
const seed = createNodeWithWidget('Sampler', 'number', 0, 'INT')
seed.widget.options.max = 1125899906842624
subgraph.add(positive.node)
subgraph.add(negative.node)
subgraph.add(seed.node)
const positiveInput = subgraph.addInput('text_1', 'STRING')
positiveInput.connect(positive.input, positive.node)
const negativeInput = subgraph.addInput('text', 'STRING')
negativeInput.connect(negative.input, negative.node)
const seedInput = subgraph.addInput('seed', 'INT')
seedInput.connect(seed.input, seed.node)
const host = createTestSubgraphNode(subgraph)
seed.widget.linkedWidgets = [
{
name: 'control_after_generate',
value: 'increment',
serialize: false,
beforeQueued: () => {},
afterQueued: () => {},
[IS_CONTROL_WIDGET]: true
} as never
]
host.widgets[2].value = 2
reorderSubgraphInputAtIndex(host, 2, 1)
seed.widget.value = 8
host.widgets[1].afterQueued?.()
expect(host.widgets[1].value).toBe(3)
expect(
useWidgetValueStore()
.getNodeWidgets(host.rootGraph.id, host.id)
.find((entry) => entry.name.startsWith('seed:'))?.value
).toBe(3)
})
})
describe('proxyWidgets is no longer re-emitted', () => {
it('does not write properties.proxyWidgets after serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode } = createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
// Ensure no pre-existing proxyWidgets property leaks through.
delete hostNode.properties.proxyWidgets
const serialized = hostNode.serialize()
expect(serialized.properties?.proxyWidgets).toBeUndefined()
expect(hostNode.properties.proxyWidgets).toBeUndefined()
})
it('preserves a pre-existing legacy proxyWidgets property without re-deriving it', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const legacy: SerializedProxyWidgetTuple[] = [['7', 'seed']]
hostNode.properties.proxyWidgets = legacy
const serialized = hostNode.serialize()
// Still serialized as-is — not deleted, not rewritten.
expect(serialized.properties?.proxyWidgets).toStrictEqual(legacy)
})
})
describe('previewExposures round-trip', () => {
it('hydrates previewExposures into the store during configure', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const rootGraphId = hostNode.rootGraph.id
const hostLocator = String(hostNode.id)
hostNode.properties.previewExposures = [
{
name: 'preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
]
hostNode._internalConfigureAfterSlots()
expect(
usePreviewExposureStore().getExposures(rootGraphId, hostLocator)
).toEqual([
{
name: 'preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
])
})
it('writes previewExposures from the store on serialize', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const store = usePreviewExposureStore()
const rootGraphId = hostNode.rootGraph.id
const hostLocator = String(hostNode.id)
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
})
const serialized = hostNode.serialize()
expect(serialized.properties?.previewExposures).toEqual([
{
name: '$$canvas-image-preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
},
{
name: 'videopreview',
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
}
])
})
it('serializes preview exposures per host instance', () => {
const subgraph = createTestSubgraph()
const firstHost = createTestSubgraphNode(subgraph, { id: 101 })
const secondHost = createTestSubgraphNode(subgraph, { id: 102 })
subgraph.rootGraph.add(firstHost)
subgraph.rootGraph.add(secondHost)
const store = usePreviewExposureStore()
const rootGraphId = firstHost.rootGraph.id
store.addExposure(rootGraphId, String(firstHost.id), {
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
})
store.addExposure(rootGraphId, String(secondHost.id), {
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
})
const firstExposures = firstHost.serialize().properties?.previewExposures
const secondExposures =
secondHost.serialize().properties?.previewExposures
expect(Array.isArray(firstExposures)).toBe(true)
expect(Array.isArray(secondExposures)).toBe(true)
if (!Array.isArray(firstExposures) || !Array.isArray(secondExposures))
throw new Error('Expected serialized previewExposures arrays')
expect(firstExposures).toEqual([
{
name: '$$canvas-image-preview',
sourceNodeId: '12',
sourcePreviewName: '$$canvas-image-preview'
}
])
expect(secondExposures).toEqual([
{
name: 'videopreview',
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
}
])
expect(firstExposures?.[0]).not.toHaveProperty('hostInstanceId')
expect(firstExposures?.[0]).not.toHaveProperty('hostNodeLocator')
expect(firstExposures?.[0]).not.toHaveProperty('rootGraphId')
expect(secondExposures?.[0]).not.toHaveProperty('hostInstanceId')
expect(secondExposures?.[0]).not.toHaveProperty('hostNodeLocator')
expect(secondExposures?.[0]).not.toHaveProperty('rootGraphId')
})
it('omits previewExposures when the store has no entries for the host', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.properties.previewExposures = [
{
name: 'stale',
sourceNodeId: '0',
sourcePreviewName: '$$canvas-image-preview'
}
]
const serialized = hostNode.serialize()
expect(serialized.properties?.previewExposures).toBeUndefined()
expect(hostNode.properties.previewExposures).toBeUndefined()
})
})
describe('proxyWidgetErrorQuarantine', () => {
it('preserves quarantine entries through serialize and is inert at runtime', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
appendHostQuarantine(hostNode, [
makeQuarantineEntry({
originalEntry: ['7', 'seed'],
reason: 'missingSourceNode',
hostValue: 42
})
])
const serialized = hostNode.serialize()
const quarantine = serialized.properties?.proxyWidgetErrorQuarantine
expect(Array.isArray(quarantine)).toBe(true)
expect(quarantine).toHaveLength(1)
// Inertness: quarantine entries do not produce widgets.
expect(
hostNode.widgets.some(
(w) => 'sourceNodeId' in w && w.sourceNodeId === '7'
)
).toBe(false)
})
it('removes the property entirely when quarantine is empty', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.properties.proxyWidgetErrorQuarantine = []
const serialized = hostNode.serialize()
expect(serialized.properties?.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostNode.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
})
})

View File

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

View File

@@ -8,6 +8,7 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigrationFlush'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
@@ -291,9 +292,9 @@ describe('SubgraphWidgetPromotion', () => {
hostNode.configure(serializedHostNode)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(interiorNode.id), 'batch_size']
])
// ADR 0009: configure() no longer writes resolved entries back to
// properties.proxyWidgets. Hydration is observable via the synthetic
// widget surface instead.
expect(hostNode.widgets).toHaveLength(1)
expect(hostNode.widgets[0].name).toBe('batch_size')
expect(hostNode.widgets[0].value).toBe(1)
@@ -356,7 +357,14 @@ describe('SubgraphWidgetPromotion', () => {
expect(widgetSourceIds).toContain(keptSamplerNodeId)
})
it('should normalize legacy prefixed proxyWidgets on configure', () => {
it('quarantines legacy prefixed proxyWidgets that target a deep leaf widget', () => {
// ADR 0009: each SubgraphNode is opaque. The legacy
// "<nestedId>: <leafId>: <leafWidgetName>" encoding referenced a deep
// leaf widget through nested chain traversal. Under the opaque model
// the migration cannot resolve that identity at the immediate level,
// so the entry is quarantined rather than reconstructed as a
// canonical promoted view. Users with this legacy state must
// re-promote through each subgraph level explicitly.
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
@@ -396,21 +404,16 @@ describe('SubgraphWidgetPromotion', () => {
}
hostNode.configure(serializedHostNode)
flushProxyWidgetMigration({ hostNode })
const promotedWidgets = hostNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
expect(promotedWidgets).toHaveLength(1)
expect(promotedWidgets[0].type).toBe('number')
expect(promotedWidgets[0].value).toBe(123)
expect(promotedWidgets[0].sourceWidgetName).toBe('noise_seed')
expect(promotedWidgets[0].disambiguatingSourceNodeId).toBe(
String(samplerNode.id)
)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
])
expect(promotedWidgets).toHaveLength(0)
expect(hostNode.properties.proxyWidgets).toBeUndefined()
const quarantine = hostNode.properties.proxyWidgetErrorQuarantine
expect(Array.isArray(quarantine) && quarantine.length).toBeGreaterThan(0)
})
it('should preserve promoted widget entries after cloning', () => {
@@ -427,31 +430,21 @@ describe('SubgraphWidgetPromotion', () => {
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
// serialize() syncs the promotion store into properties.proxyWidgets
const serialized = hostNode.serialize()
const originalProxyWidgets = serialized.properties!
.proxyWidgets as string[][]
expect(originalProxyWidgets.length).toBeGreaterThan(0)
expect(
originalProxyWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
// Simulate clone: create a second SubgraphNode configured from serialized data
// ADR 0009: clone preservation no longer relies on properties.proxyWidgets.
// The promoted widgets are derived from the linked SubgraphInputs that
// come through the serialized inputs/links, so the host's own widgets
// getter should expose the promoted view after configure.
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
expect(
cloneProxyWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
const promotedNames = cloneNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
.map((widget) => widget.sourceWidgetName)
// Clone's proxyWidgets should reference the same interior node
const originalNodeIds = originalProxyWidgets.map(([nodeId]) => nodeId)
const cloneNodeIds = cloneProxyWidgets.map(([nodeId]) => nodeId)
expect(cloneNodeIds).toStrictEqual(originalNodeIds)
expect(promotedNames).toContain('text')
})
})

View File

@@ -19,15 +19,16 @@ interface DeduplicationResult {
* they are configured. This prevents widget store key collisions when
* multiple subgraph copies contain nodes with the same IDs.
*
* Also patches proxyWidgets in root-level nodes that reference the
* remapped inner node IDs.
* Also patches legacy proxyWidgets in root-level nodes that reference the
* remapped inner node IDs. The ADR 0009 migration flush consumes these tuples
* after configure.
*
* Returns deep clones of the inputs — the originals are never mutated.
*
* @param subgraphs - Serialized subgraph definitions to deduplicate
* @param reservedNodeIds - Node IDs already in use by root-level nodes
* @param state - Graph state containing the `lastNodeId` counter (mutated)
* @param rootNodes - Optional root-level nodes with proxyWidgets to patch
* @param rootNodes - Optional root-level nodes with legacy proxyWidgets to patch
*/
export function deduplicateSubgraphNodeIds(
subgraphs: ExportedSubgraph[],
@@ -197,7 +198,7 @@ export function topologicalSortSubgraphs(
return sorted
}
/** Patches proxyWidgets in root-level SubgraphNode instances. */
/** Patches legacy proxyWidgets in root-level SubgraphNode instances. */
function patchProxyWidgets(
rootNodes: ISerialisedNode[],
subgraphIdSet: Set<string>,

View File

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

View File

@@ -17,7 +17,6 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -26,7 +25,6 @@ export interface DrawWidgetOptions {
width: number
/** Synonym for "low quality". */
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
previewImages?: HTMLImageElement[]
@@ -206,17 +204,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
}
getOutlineColor(suppressPromotedOutline = false) {
const graphId = this.node.graph?.rootGraph.id
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
getOutlineColor(_suppressPromotedOutline = false) {
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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