mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-28 00:45:03 +00:00
## 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>
142 lines
4.6 KiB
TypeScript
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 }
|
|
}
|