mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-19 02:06:38 +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>
283 lines
8.6 KiB
TypeScript
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
|
|
}
|
|
})
|