Files
ComfyUI_frontend/src/composables/node/usePromotedPreviews.ts
Alexander Brown 0157b47024 feat(subgraph): Subgraph Link Only Promotion (ADR 0009) + migration/store hygiene (#12197)
## Summary

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

## What changes

### Subgraph Link Only Promotion (ADR 0009)

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

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

### Supporting refactors

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

### Tests

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

## Review focus

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

## Breaking changes

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

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

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-05-27 00:29:11 -07:00

142 lines
4.6 KiB
TypeScript

import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
sourceNodeId: string
sourceWidgetName: string
type: 'image' | 'video' | 'audio'
urls: string[]
}
const PREVIEW_TYPES_BY_MEDIA = {
video: 'video',
audio: 'audio'
} as const satisfies Partial<Record<string, PromotedPreview['type']>>
function getPreviewMediaType(node: LGraphNode): PromotedPreview['type'] {
const media = node.previewMediaType
if (media && media in PREVIEW_TYPES_BY_MEDIA) {
return PREVIEW_TYPES_BY_MEDIA[media as keyof typeof PREVIEW_TYPES_BY_MEDIA]
}
return 'image'
}
export function usePromotedPreviews(
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
) {
const previewExposureStore = usePreviewExposureStore()
const nodeOutputStore = useNodeOutputStore()
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: string,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
leafHost.subgraph.id,
leafSourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
const reactiveExecutionOutputs =
nodeOutputStore.getNodeOutputByExecutionId(leafExecutionId)
const reactiveExecutionPreviews =
nodeOutputStore.getNodePreviewImagesByExecutionId(leafExecutionId)
const hasAnySource =
reactiveOutputs?.images?.length ||
reactivePreviews?.length ||
reactiveExecutionOutputs?.images?.length ||
reactiveExecutionPreviews?.length
if (!hasAnySource) return undefined
return (
nodeOutputStore.getNodeImageUrlsByExecutionId(
leafExecutionId,
interiorNode
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
)
}
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
const exposures = previewExposureStore.getExposures(
rootGraphId,
hostLocator
)
if (!exposures.length) return []
const hostNodesByLocator = new Map<string, SubgraphNode>([
[hostLocator, node]
])
function resolveNestedHost(
rootGraphId: UUID,
currentHostLocator: string,
sourceNodeId: string
) {
const currentHost = hostNodesByLocator.get(currentHostLocator)
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
if (!(sourceNode instanceof SubgraphNode)) return undefined
const pathLocator = `${currentHostLocator}:${sourceNode.id}`
const definitionLocator = String(sourceNode.id)
const hasPathExposures =
previewExposureStore.getExposures(rootGraphId, pathLocator).length > 0
const nestedHostLocator = hasPathExposures
? pathLocator
: definitionLocator
hostNodesByLocator.set(nestedHostLocator, sourceNode)
return { rootGraphId, hostNodeLocator: nestedHostLocator }
}
return exposures.flatMap((exposure): PromotedPreview[] => {
const resolved = previewExposureStore.resolveChain(
rootGraphId,
hostLocator,
exposure.name,
resolveNestedHost
)
const leaf = resolved?.leaf ?? {
sourceNodeId: exposure.sourceNodeId,
sourcePreviewName: exposure.sourcePreviewName
}
const leafHostLocator =
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
if (!interiorNode) return []
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
`${leafHostLocator}:${leaf.sourceNodeId}`,
interiorNode
)
if (!urls?.length) return []
return [
{
sourceNodeId: leaf.sourceNodeId,
sourceWidgetName: leaf.sourcePreviewName,
type: getPreviewMediaType(interiorNode),
urls
}
]
})
})
return { promotedPreviews }
}