Files
ComfyUI_frontend/src/stores/appModeStore.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

283 lines
8.6 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
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,
LinearInput
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
getWidgetEntityIdForNode,
resolveNode,
resolveNodeWidget
} from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
function findWidgetByEntityId(
rootGraph: LGraph,
entityId: WidgetEntityId
): IBaseWidget | undefined {
for (const node of rootGraph.nodes) {
const widget = node.widgets?.find(
(w) => getWidgetEntityIdForNode(node, w) === entityId
)
if (widget) return widget
}
return undefined
}
export function nodeTypeValidForApp(type: string) {
return !['Note', 'MarkdownNote'].includes(type)
}
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
const emptyWorkflowDialog = useEmptyWorkflowDialog()
const showVueNodeSwitchPopup = ref(false)
const selectedInputs = ref<LinearInput[]>([])
const selectedOutputs = ref<NodeId[]>([])
const hasOutputs = computed(() => !!selectedOutputs.value.length)
const hasNodes = computed(() => {
// Nodes are not reactive, so trigger recomputation when workflow changes
void workflowStore.activeWorkflow
void mode.value
return !!app.rootGraph?.nodes?.length
})
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
const rawInputs = data?.inputs ?? []
const rawOutputs = data?.outputs ?? []
const rootGraph = app.rootGraph
if (!rootGraph) {
return { inputs: rawInputs, outputs: rawOutputs }
}
return {
inputs: rawInputs
.map((input) => upgradeAndValidateInput(input, rootGraph))
.filter((entry): entry is LinearInput => entry !== null),
outputs: ChangeTracker.isLoadingGraph
? rawOutputs
: rawOutputs.filter((nodeId) => resolveNode(nodeId))
}
}
function buildEntry(
entityId: WidgetEntityId,
name: string,
config: InputWidgetConfig | undefined
): LinearInput {
return config === undefined ? [entityId, name] : [entityId, name, config]
}
function upgradeAndValidateInput(
input: LinearInput,
rootGraph: NonNullable<typeof app.rootGraph>
): LinearInput | null {
const [storedId, widgetName, config] = input
if (typeof storedId === 'string' && isWidgetEntityId(storedId)) {
const widget = findWidgetByEntityId(rootGraph, storedId)
if (widget) return buildEntry(storedId, widgetName, config)
const { nodeId } = parseWidgetEntityId(storedId)
if (rootGraph.getNodeById?.(nodeId)) {
return buildEntry(storedId, widgetName, config)
}
return null
}
if (typeof storedId === 'string' && storedId.includes(':')) {
const [, widget] = resolveNodeWidget(storedId, widgetName)
if (!widget?.entityId) return null
return buildEntry(widget.entityId, widgetName, config)
}
const directNode = rootGraph.getNodeById?.(storedId)
const directWidget = directNode?.widgets?.find((w) => w.name === widgetName)
if (directNode && directWidget) {
const derivedId = getWidgetEntityIdForNode(directNode, directWidget)
if (derivedId) return buildEntry(derivedId, widgetName, config)
}
const matches: LinearInput[] = rootGraph.nodes.flatMap((node) => {
if (!(node instanceof SubgraphNode)) return []
return node.inputs.flatMap((inputSlot): LinearInput[] => {
const widget = inputSlot._widget
if (!widget || !isPromotedWidgetView(widget)) return []
if (
widget.sourceNodeId !== String(storedId) ||
widget.sourceWidgetName !== widgetName
) {
return []
}
return widget.entityId
? [buildEntry(widget.entityId, inputSlot.name, config)]
: []
})
})
if (matches.length === 1) return matches[0]
if (matches.length > 1) {
console.warn(
'[appModeStore] dropping ambiguous legacy selectedInput tuple',
{ storedId, widgetName }
)
return null
}
console.warn(
'[appModeStore] dropping legacy selectedInput tuple — no canonical identity available',
{ storedId, widgetName }
)
return null
}
function loadSelections(data: Partial<LinearData> | undefined) {
const { inputs, outputs } = pruneLinearData(data)
selectedInputs.value = inputs
selectedOutputs.value = outputs
}
function resetSelectedToWorkflow() {
const { activeWorkflow } = workflowStore
if (!activeWorkflow) return
const source =
activeWorkflow.changeTracker?.activeState?.extra?.linearData ??
activeWorkflow.initialState?.extra?.linearData
loadSelections(source)
}
useEventListener(
() => app.rootGraph?.events,
'configured',
resetSelectedToWorkflow
)
watch(
() =>
isBuilderMode.value
? { inputs: selectedInputs.value, outputs: selectedOutputs.value }
: null,
(data) => {
if (!data || ChangeTracker.isLoadingGraph) return
const graph = app.rootGraph
if (!graph) return
const extra = (graph.extra ??= {})
extra.linearData = {
inputs: [...data.inputs],
outputs: [...data.outputs]
}
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
},
{ deep: true }
)
let unwatchReadOnly: (() => void) | undefined
function enforceReadOnly(inSelect: boolean) {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
unwatchReadOnly?.()
if (inSelect)
unwatchReadOnly = watch(
() => state.readOnly,
() => (state.readOnly = true)
)
}
function autoEnableVueNodes(inSelect: boolean) {
if (!inSelect) return
if (!settingStore.get('Comfy.VueNodes.Enabled')) {
void settingStore.set('Comfy.VueNodes.Enabled', true)
if (!settingStore.get('Comfy.AppBuilder.VueNodeSwitchDismissed')) {
showVueNodeSwitchPopup.value = true
}
}
}
watch(isSelectMode, (inSelect) => {
enforceReadOnly(inSelect)
autoEnableVueNodes(inSelect)
})
function enterBuilder() {
if (!hasNodes.value) {
emptyWorkflowDialog.show({
onEnterBuilder: () => enterBuilder(),
onDismiss: () => setMode('graph')
})
return
}
resetSelectedToWorkflow()
useSidebarTabStore().activeSidebarTabId = null
setMode(
mode.value === 'app' && hasOutputs.value
? 'builder:arrange'
: 'builder:inputs'
)
}
function exitBuilder() {
resetSelectedToWorkflow()
setMode('graph')
}
function removeSelectedInput(widget: IBaseWidget) {
const targetEntityId = widget.entityId
if (!targetEntityId) return
const index = selectedInputs.value.findIndex(
([id]) => id === targetEntityId
)
if (index !== -1) selectedInputs.value.splice(index, 1)
}
function updateInputConfig(widget: IBaseWidget, config: InputWidgetConfig) {
const targetEntityId = widget.entityId
if (!targetEntityId) return
const index = selectedInputs.value.findIndex(
([id]) => id === targetEntityId
)
if (index === -1) return
const [id, type, options] = selectedInputs.value[index]
selectedInputs.value.splice(index, 1, [id, type, { ...options, ...config }])
}
return {
enterBuilder,
exitBuilder,
hasNodes,
hasOutputs,
loadSelections,
pruneLinearData,
removeSelectedInput,
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs,
updateInputConfig,
showVueNodeSwitchPopup
}
})