Compare commits

...

25 Commits

Author SHA1 Message Date
DrJKL
aa4b6c70c5 refactor: migrate proxy widget migration onto the shared projection
resolveSourceWidget builds the nested-subgraph source widget via
promotedInputWidget instead of a hand-rolled `as IBaseWidget` literal
carrying source fields. Consolidate the two bespoke promoted-widget test
helpers in promotionUtils.test into one thin wrapper over the real
projection, dropping the synthetic source-field shapes. PromotedWidgetSource
is now confined to the migration/schema layer where promotion sources are
persisted tuples rather than link-derivable.
2026-06-03 00:01:18 -07:00
DrJKL
93fdbab098 refactor: resolve promoted widget identity on demand, drop isPromotedWidget
Replace the isPromotedWidget duck-type and the source fields read off
widget objects with widgetPromotedSource(node, widget) in WidgetActions
(isLinked/handleHideInput) and SectionWidgets (isWidgetShownOnParents).
getInputSpecForWidget now resolves the real interior node-definition spec
for a subgraph node instead of fabricating one from store options, and
renameWidget collapses to a widgetId-keyed label-only write that never
touches name/widgetId. Delete the now-unused PromotedWidget type and
isPromotedWidget guard.
2026-06-02 23:41:53 -07:00
DrJKL
3471de82fb refactor: build subgraph parameter rows from the shared projection
Replace the hand-rolled promoted-widget builders in TabSubgraphInputs
(buildPromotedWidget/getPromotedWidgets) and SubgraphEditor
(buildPromotedDescriptors + PromotedDescriptor) with promotedInputWidgets
/promotedInputWidget. SubgraphEditor rows now carry the projected widget
plus the host input slot and resolve their interior source on demand for
demote/re-promote, so no source identity is denormalized onto the rows.
2026-06-02 23:23:33 -07:00
DrJKL
e797fc6c3a refactor: project promoted subgraph inputs through one module
Add src/core/graph/subgraph/promotedInputWidget.ts as the single
projection from a subgraph input slot to an ordinary store-backed
widget descriptor, and route useGraphNodeManager through it
(promotedInputWidgets + the shared safeWidgetMapper), deleting the
hand-rolled promotedInputToSafeWidget.

A projected widget name is now the input slot name (stable, unique,
the widgetId derives from it); the interior source widget name is
carried separately on SafeWidgetData (sourceWidgetName) only for
missing-model lookups, which key by the interior name. Drop the
redundant slotName and promotedLabel fields.
2026-06-02 23:14:03 -07:00
DrJKL
8623384c79 fix: keep promoted parameter rows stable across value edits
buildPromotedWidget read the store value at build time, so the row-list computed
reactively rebuilt on every edit, producing new descriptor objects and churning
getStableWidgetRenderKey — re-rendering the whole panel. Read type/value/options
through live getters instead so identity stays stable and value updates in place.
2026-06-02 21:05:58 -07:00
DrJKL
d01424f99e refactor: rename PromotedWidgetView type to PromotedWidget
The promoted host widget is no longer a synthetic view; reduce the type to the
promoted-source shape and rename the structural guard to isPromotedWidget.
2026-06-02 19:55:21 -07:00
DrJKL
0b99394a34 chore: remove widgetNodeTypeGuard, orphaned by the widget view deletion 2026-06-02 19:52:07 -07:00
DrJKL
2a4102690d feat: show subgraph node promoted widgets on the parameters tab
Build store-backed parameter rows for a subgraph node's promoted inputs from
node.inputs, unified with ordinary node widgets: display resolves through the
host widgetId and value writes go through widgetValueStore. Route the panel's
value write through widgetId so promoted inputs persist.
2026-06-02 19:47:35 -07:00
DrJKL
574c8d37d9 refactor: delete PromotedWidgetView class and collapse dead view guards
Remove the synthetic promoted-widget view class and PromotedWidgetViewManager.
Promoted host widgets are fully store-backed by widgetId, so the now-dead
isPromotedWidgetView branches over node.widgets/input._widget collapse to the
plain path. SubgraphNode loses its view machinery; getSlotFromWidget matches by
widgetId. Resolve the legacy selectedInput migration via resolveSubgraphInputTarget.

The structural isPromotedWidgetView guard and PromotedWidgetView type remain in
promotedWidgetTypes for the right-panel parameter components, pending their
store-driven rewrite.
2026-06-02 19:36:53 -07:00
DrJKL
7809c2faf8 feat: resolve nested promoted subgraph widgets without a widget view
Make resolveConcretePromotedWidget descend through subgraph inputs instead of
following synthetic widget views, so two-layer promotions resolve to the
deepest concrete widget. SubgraphNode assigns a widgetId to inputs whose source
is itself a promoted subgraph input, and dispatches the concrete interior
widget (not a view) on widget-promoted.
2026-06-02 18:51:24 -07:00
DrJKL
3e462b663a refactor: resolve nested proxy source via plain ref, not a widget view
The nested-subgraph branch of resolveSourceWidget now returns a plain source
ref (name/type/options/value/widgetId) instead of a PromotedWidgetView;
getSlotFromWidget still locates the backing slot by widgetId.
2026-06-02 18:40:54 -07:00
DrJKL
54a3b035b8 refactor: derive subgraph editor promoted rows from a plain descriptor
Replace the per-input PromotedWidgetView with a source descriptor built from
node inputs, and demote linked promotions by source via demotePromotedInput.
2026-06-02 18:35:36 -07:00
DrJKL
2082f5f43b refactor: build promoted subgraph widgets without a widget view
Map promoted subgraph inputs to SafeWidgetData directly from input.widgetId,
the resolved interior source widget, and widget state, instead of constructing
an ephemeral PromotedWidgetView per input.
2026-06-02 18:27:20 -07:00
DrJKL
c69f009775 chore: remove unused exports
Amp-Thread-ID: https://ampcode.com/threads/T-019e8af0-a3a2-7713-ba7e-a5579938f388
Co-authored-by: Amp <amp@ampcode.com>
2026-06-02 17:50:46 -07:00
DrJKL
749425d1ea test: update useGraphNodeManager tests to widget-id model
Remove the obsolete test that drove mapping through a persistent promoted view
on node.widgets (now empty; duplicate-named distinct identity is covered
elsewhere). Skip the two-layer nested promotion test as a documented known
limitation pending multi-level resolution support.
2026-06-02 17:41:14 -07:00
DrJKL
2157901a74 test: update useGLSLPreview getWidget mock to single widgetId arg
The impl now calls getWidget(widgetId(graphId, nodeId, name)); the test mock
still used the old three-arg signature and looked up by the wrong key. Extract
the widget name from the widget id.
2026-06-02 17:41:13 -07:00
DrJKL
b78de7c41f test: update litegraph-core tests to the empty-widgets model
SubgraphNode.widgets is now always empty (host widgets are store-backed), and
widget state nodeId is the string parsed from the widget id. Fix the BaseWidget
nodeId assertion, drop the stale subgraphNode.widgets assertion in SubgraphIO
(inputs already cover it), and remove the obsolete manual-widget-injection test.
2026-06-02 17:41:12 -07:00
DrJKL
3e8b0f56e9 fix: break litegraph init cycle via late-bound LiteGraph holder
draw.ts and BaseWidget.ts imported the LiteGraph singleton from the barrel,
which re-entered the barrel during BaseWidget initialization and evaluated
LegacyWidget extends BaseWidget before BaseWidget was defined (crashing any
entry that loaded a widget before the barrel, e.g. useGraphNodeManager tests).
Add a leaf litegraphInstance holder (type-only import) read at runtime via
litegraph(); the barrel constructs and registers the instance.
2026-06-02 17:41:11 -07:00
DrJKL
386e2d2796 refactor: derive subgraph editor promoted rows from inputs
The panel read promoted views off node.widgets, which is now empty. Build the
synth views from subgraph inputs carrying a widgetId (mirroring
useGraphNodeManager) so promoted rows, reorder, and demote work against
store-backed host widgets. Update tests to assert on promoted inputs.
2026-06-02 17:41:10 -07:00
DrJKL
914ceae014 refactor: derive subgraph widget input spec from widget state
getInputSpecForWidget resolved promoted views to read the source widget's
options. With host widgets store-backed, build the input spec from the widget's
widgetId state options (default, etc.) instead of following a view.
2026-06-02 17:41:10 -07:00
DrJKL
c87de750ca refactor: drop dead promoted-view branch from error clearing hooks
Promoted host widgets no longer surface as views on the subgraph node, so
onWidgetChanged never receives one. Remove resolvePromotedExecId and clear
errors using the node's own execution id. Drop the obsolete test that drove
clearing through a host-side promoted view.
2026-06-02 17:41:09 -07:00
DrJKL
c0fc78554a test: build promoted view directly in host-wins tests
SubgraphNode.widgets is empty, so the host-wins semantics tests can no longer
find the view there. Construct it via createPromotedWidgetView, mirroring how
useGraphNodeManager builds the synth view.
2026-06-02 17:41:08 -07:00
DrJKL
1f50a78530 refactor: finish entityId to widgetId field rename
The type rename WidgetEntityId->WidgetId left the widget field still named
entityId. Drop the entityId aliases on IBaseWidget/BaseWidget/PromotedWidgetView
and SafeWidgetData, and read widget.widgetId everywhere. Source widget name from
widget state when present so promoted host widgets render their canonical
identity instead of stale data.
2026-06-02 17:41:05 -07:00
DrJKL
5098fb8fa7 refactor: decouple widget render key and host-widget resolver from PromotedWidgetView
The render-key prefix was cosmetic and the host-widget resolver's promoted-chain
following was unreachable (subgraph host widgets never populate node.widgets).
Reduce both to view-free lookups and drop the obsolete chain-following tests.
2026-06-02 17:41:04 -07:00
DrJKL
c0b1ed9770 refactor: make widgetValueStore WidgetId-native and migrate subgraph host widgets
Rename WidgetEntityId to WidgetId throughout, delete widgetValueIO in favor
of a WidgetId-native widgetValueStore, and represent subgraph promoted host
widgets as store-backed widget state addressed by input.widgetId. Migrate
runtime consumers (graph node manager, processed widgets, right-side panel,
price/error utilities) toward widget-id state.
2026-06-02 17:40:58 -07:00
82 changed files with 2132 additions and 3857 deletions

View File

@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
}
}
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
function removeSelectedWidgetId(widgetId: WidgetId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
const widgetId = widget.widgetId
if (!widgetId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
if (index === -1)
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
resolvedInputs.value.map(
(entry) =>
[entry.entityId, getWidgetBounding(entry)] as [
[entry.widgetId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedEntityId(entry.entityId)"
:remove="() => removeSelectedWidgetId(entry.widgetId)"
/>
</template>
</DraggableList>

View File

@@ -62,7 +62,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { entityId, node, widget, config } = entry
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
@@ -72,7 +72,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.entityId === entityId
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
@@ -81,7 +81,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: entityId,
key: widgetId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,

View File

@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
@@ -22,14 +22,14 @@ vi.mock('@/scripts/app', () => ({
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
}))
})
}

View File

@@ -6,13 +6,13 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import { isWidgetId, parseWidgetId } from '@/world/entityIds'
export type ResolvedSelection =
| {
status: 'resolved'
entityId: WidgetEntityId
widgetId: WidgetId
node: LGraphNode
widget: IBaseWidget
displayName: string
@@ -20,7 +20,7 @@ export type ResolvedSelection =
}
| {
status: 'unknown'
entityId: WidgetEntityId
widgetId: WidgetId
displayName: string
config?: InputWidgetConfig
}
@@ -54,16 +54,16 @@ export function useResolvedSelectedInputs() {
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
([widgetId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetId(widgetId)) return []
const { nodeId, name } = parseWidgetId(widgetId)
const node = rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', entityId, displayName, config }]
return [{ status: 'unknown', widgetId, displayName, config }]
}
return [
{ status: 'resolved', entityId, node, widget, displayName, config }
{ status: 'resolved', widgetId, node, widget, displayName, config }
]
}
)

View File

@@ -12,14 +12,15 @@ import {
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
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 type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -146,16 +147,17 @@ function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
): boolean {
const source = widgetPromotedSource(widgetNode, widget)
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
if (source) {
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
? source.nodeId
: String(widgetNode.id)
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: source.widgetName
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
@@ -234,7 +236,10 @@ function navigateToErrorTab() {
rightSidePanelStore.openPanel('errors')
}
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
// are addressed by widgetId; writing there keeps the displayed value in sync.
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
widget.value = value
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
@@ -245,18 +250,18 @@ function handleResetAllWidgets() {
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
const defaultValue = getWidgetDefaultValue(spec)
if (defaultValue !== undefined) {
writeWidgetValue(widget, defaultValue)
setWidgetValue(widget, defaultValue)
}
}
}
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
if (newValue === undefined) return
writeWidgetValue(widget, newValue)
setWidgetValue(widget, newValue)
}
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
writeWidgetValue(widget, newValue)
setWidgetValue(widget, newValue)
}
defineExpose({

View File

@@ -0,0 +1,127 @@
import { render } 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 { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/world/entityIds'
import TabSubgraphInputs from './TabSubgraphInputs.vue'
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
})
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
rows: []
}
const SectionWidgetsStub = {
props: ['widgets', 'node', 'parents'],
setup(props: Record<string, unknown>) {
captured.rows = props.widgets as {
node: LGraphNode
widget: IBaseWidget
}[]
return () => null
}
}
function buildHostWithPromotedSeed(): {
host: SubgraphNode
sourceNode: LGraphNode
} {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const graph = host.graph as LGraph
graph.add(host)
const sourceNode = new LGraphNode('Sampler')
const input = sourceNode.addInput('seed', 'INT')
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(sourceNode)
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
return { host, sourceNode }
}
function renderPanel(node: SubgraphNode) {
return render(TabSubgraphInputs, {
props: { node },
global: {
plugins: [i18n],
stubs: {
SectionWidgets: SectionWidgetsStub,
AsyncSearchInput: true,
CollapseToggleButton: true
}
}
})
}
describe('TabSubgraphInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
captured.rows = []
vi.clearAllMocks()
})
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow).toBeDefined()
expect(seedRow?.node.id).toBe(host.id)
expect(seedRow?.widget.type).toBe('number')
expect(seedRow?.widget.widgetId).toBe(
widgetId(host.rootGraph.id, host.id, 'seed')
)
expect(seedRow?.widget.value).toBe(42)
})
it('reflects the current host widget value from the store', () => {
const { host } = buildHostWithPromotedSeed()
const id = widgetId(host.rootGraph.id, host.id, 'seed')
useWidgetValueStore().setValue(id, 7)
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow?.widget.value).toBe(7)
})
it('reflects value changes through the same descriptor without rebuilding it', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
expect(seedRow.widget.value).toBe(42)
// A value edit must not require a new descriptor object: the same row
// reflects the store change via its live getter, keeping render keys stable.
useWidgetValueStore().setValue(
widgetId(host.rootGraph.id, host.id, 'seed'),
100
)
expect(seedRow.widget.value).toBe(100)
})
})

View File

@@ -3,14 +3,13 @@ import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} 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 AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
@@ -45,32 +44,6 @@ const isAllCollapsed = computed({
})
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
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,
async (section) => {
@@ -93,7 +66,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
return getPromotedWidgets().map((widget) => ({ node, widget }))
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {

View File

@@ -5,8 +5,9 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import {
demotePromotedInput,
demoteWidget,
isLinkedPromotion,
promoteWidget
@@ -16,6 +17,7 @@ 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 { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -45,8 +47,10 @@ const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
if (!node.isSubgraphNode()) return false
const source = widgetPromotedSource(node, widget)
if (!source) return false
return isLinkedPromotion(node, source.nodeId, source.widgetName)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
@@ -64,9 +68,16 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
const hasDefault = computed(() => defaultValue.value !== undefined)
const currentValue = computed(
() =>
(widget.widgetId &&
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
widget.value
)
const isCurrentValueDefault = computed(() => {
if (!hasDefault.value) return true
return isEqual(widget.value, defaultValue.value)
return isEqual(currentValue.value, defaultValue.value)
})
async function handleRename() {
@@ -77,21 +88,15 @@ async function handleRename() {
function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const source = widgetPromotedSource(node, widget)
if (source) {
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
demotePromotedInput(parent, {
sourceNodeId,
sourceWidgetName: source.widgetName
})
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -7,6 +7,8 @@ import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -42,10 +44,6 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
() => ({
@@ -96,43 +94,6 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
} as IBaseWidget
}
/**
* Creates a mock PromotedWidgetView that mirrors the real class:
* properties like name, type, value, options are prototype getters,
* NOT own properties — so object spread loses them.
*/
function createMockPromotedWidgetView(
sourceOptions: IBaseWidget['options'] = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
): IBaseWidget {
class MockPromotedWidgetView {
readonly sourceNodeId = '42'
readonly sourceWidgetName = 'ckpt_name'
readonly serialize = false
get name(): string {
return 'ckpt_name'
}
get type(): string {
return 'combo'
}
get value(): unknown {
return 'model_a.safetensors'
}
get options(): IBaseWidget['options'] {
return sourceOptions
}
get label(): string | undefined {
return undefined
}
get y(): number {
return 0
}
}
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
}
function renderWidgetItem(
widget: IBaseWidget,
node: LGraphNode = createMockNode()
@@ -167,7 +128,7 @@ describe('WidgetItem', () => {
vi.clearAllMocks()
})
describe('promoted widget options', () => {
describe('widget state rendering', () => {
it('passes options from a regular widget to the widget component', () => {
const widget = createMockWidget({
options: { values: ['a', 'b', 'c'] }
@@ -180,35 +141,63 @@ describe('WidgetItem', () => {
})
})
it('passes options from a PromotedWidgetView to the widget component', () => {
it('passes options from widget state to the widget component', () => {
const expectedOptions = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const widget = createMockPromotedWidgetView(expectedOptions)
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: expectedOptions
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.options).toEqual(expectedOptions)
})
it('passes type from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes type from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, type: 'string' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.type).toBe('combo')
})
it('passes name from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes name from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.name).toBe('ckpt_name')
})
it('passes value from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
it('passes value from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -17,11 +16,12 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
useWidgetValueStore,
stripGraphPrefix
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { renameWidget } from '@/utils/widgetUtil'
@@ -67,35 +67,32 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const source = resolvePromotedWidgetSource(node, widget)
return source ?? { node, widget }
}
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
const bareNodeId = stripGraphPrefix(String(node.id))
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
: graphId
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
: undefined
const widgetName = widgetState?.name ?? widget.name
const widgetType = widgetState?.type ?? widget.type
return {
name: widget.name,
type: widget.type,
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: widgetState?.options ?? widget.options,
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
controlWidget: getControlWidget(sourceWidget)
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
controlWidget: getControlWidget(widget)
}
})
const sourceNodeName = computed((): string | null => {
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
if (!sourceNode) return null
const displayNodeName = computed((): string | null => {
if (!node) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(sourceNode, {
return resolveNodeDisplayName(node, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
@@ -167,10 +164,10 @@ const displayLabel = customRef((track, trigger) => {
/>
<span
v-if="(showNodeName || hasParents) && sourceNodeName"
v-if="(showNodeName || hasParents) && displayNodeName"
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
>
{{ sourceNodeName }}
{{ displayNodeName }}
</span>
<div
v-if="!hiddenWidgetActions"

View File

@@ -14,8 +14,9 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import SubgraphEditor from './SubgraphEditor.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type DraggableList from '@/components/common/DraggableList.vue'
@@ -167,11 +168,20 @@ describe('SubgraphEditor', () => {
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
const reversed = [
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
] as PromotedRow[]
const rowFor = (sourceNode: LGraphNode) => {
const input = host.inputs.find((input) => {
if (!input.widgetId) return false
const target = resolveSubgraphInputTarget(host, input.name)
return target?.nodeId === String(sourceNode.id)
})!
return {
kind: 'promoted',
node: sourceNode,
input,
widget: promotedInputWidget(input)!
}
}
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
listSetter?.(reversed)
await nextTick()
@@ -213,13 +223,13 @@ describe('SubgraphEditor', () => {
}
})
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const hideAllLink = within(shown).getByText('Hide all')
await userEvent.click(hideAllLink)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
})
it('removes the exposure when a preview row without a real source widget is demoted', async () => {

View File

@@ -5,9 +5,8 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demotePromotedInput,
demoteWidget,
getPromotableWidgets,
isLinkedPromotion,
@@ -16,8 +15,14 @@ import {
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import {
promotedInputSource,
promotedInputWidget
} from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -33,7 +38,8 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
widget: PromotedWidgetView
input: INodeInputSlot
widget: IBaseWidget
}
type PreviewRow = {
kind: 'preview'
@@ -54,11 +60,23 @@ const activeNode = computed(() => {
return undefined
})
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
function refreshPromotedWidgets() {
promotedWidgets.value = activeNode.value?.widgets ?? []
const promotedRows = shallowRef<readonly PromotedRow[]>([])
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
return node.inputs.flatMap((input): PromotedRow[] => {
const widget = promotedInputWidget(input)
if (!widget) return []
const source = promotedInputSource(node, input)
if (!source) return []
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, input, widget }]
})
}
watch(activeNode, refreshPromotedWidgets, { immediate: true })
function refreshPromotedRows() {
const node = activeNode.value
promotedRows.value = node ? buildPromotedRows(node) : []
}
watch(activeNode, refreshPromotedRows, { immediate: true })
useEventListener(
() => activeNode.value?.subgraph.events,
[
@@ -68,34 +86,29 @@ useEventListener(
'removing-input',
'inputs-reordered'
],
refreshPromotedWidgets
refreshPromotedRows
)
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
const node = activeNode.value
return node ? promotedInputSource(node, row.input) : undefined
}
const activeRows = computed<ActiveRow[]>(() => {
const node = activeNode.value
if (!node) return []
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
return [...promotedRows.value, ...getActivePreviewRows(node)]
})
const activePromotedRows = computed<PromotedRow[]>({
get() {
const node = activeNode.value
return node ? getActivePromotedRows(node) : []
return [...promotedRows.value]
},
set(value: PromotedRow[]) {
updateActivePromotedRows(value, activePromotedRows.value)
}
})
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, widget }]
})
}
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const hostLocator = String(node.id)
const rootGraphId = node.rootGraph.id
@@ -130,7 +143,7 @@ function updateActivePromotedRows(
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map((row) => row.widget)
value.map((row) => ({ widgetId: row.widget.widgetId }))
)
}
refreshPromotedWidgetRendering()
@@ -151,9 +164,11 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
})
function activeRowSourceKey(row: ActiveRow): string {
return row.kind === 'promoted'
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
if (row.kind === 'promoted') {
const source = promotedRowSource(row)
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
}
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
}
const candidateWidgets = computed<WidgetItem[]>(() => {
@@ -228,18 +243,16 @@ function rowDisplayName(row: ActiveRow): string {
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
const source = promotedRowSource(row)
return (
!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(row.node.id),
row.widget.sourceWidgetName
)
!!source &&
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
)
}
function promotedRowKey(row: PromotedRow): string {
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
return `${row.node.id}: ${row.widget.name}`
}
function rowKey(row: ActiveRow): string {
@@ -256,7 +269,14 @@ function demoteRow(row: ActiveRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
if (row.kind === 'promoted') {
demoteWidget(row.node, row.widget, [subgraphNode])
const source = promotedRowSource(row)
if (source) {
demotePromotedInput(subgraphNode, {
sourceNodeId: source.nodeId,
sourceWidgetName: source.widgetName
})
}
refreshPromotedWidgetRendering()
return
}
if (row.realWidget) {
@@ -274,7 +294,11 @@ function demoteRow(row: ActiveRow) {
function promotePromotedRow(row: PromotedRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(row.node, row.widget, [subgraphNode])
const source = promotedRowSource(row)
const sourceWidget = source
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
: undefined
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
}
function promoteCandidate([node, widget]: WidgetItem) {

View File

@@ -251,59 +251,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).toBeNull()
expect(mediaStore.missingMediaCandidates).toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{ values: ['model.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
// with that name so the slot-name filter matches.
seedRequiredInputMissingNodeError(
store,
interiorExecId,
promotedWidget!.name
)
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'other_model.safetensors',
'model.safetensors',
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
})
describe('installErrorClearingHooks lifecycle', () => {

View File

@@ -6,11 +6,8 @@
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
LGraphEventMode,
NodeSlotType
@@ -45,24 +42,6 @@ import {
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
function resolvePromotedExecId(
rootGraph: LGraph,
node: LGraphNode,
widget: IBaseWidget,
hostExecId: string
): string {
if (!isPromotedWidgetView(widget)) return hostExecId
const result = resolveConcretePromotedWidget(
node,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved' && result.resolved.node) {
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
}
return hostExecId
}
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
@@ -103,20 +82,10 @@ function installNodeHooks(node: LGraphNode): void {
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const execId = resolvePromotedExecId(
app.rootGraph,
node,
widget,
hostExecId
)
const widgetName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
execId,
hostExecId,
widget.name,
widget.name,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
)

View File

@@ -1,13 +1,11 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import { widgetId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -47,9 +45,10 @@ describe('Node Reactivity', () => {
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
const id = widgetId(graph.id, node.id, 'testnum')
expect(store.getWidget(id)?.value).toBe(2)
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(id)
if (!state) throw new Error('Expected widget state to exist')
const onValueChange = vi.fn()
@@ -74,7 +73,7 @@ describe('Node Reactivity', () => {
})
await nextTick()
const state = store.getWidget(graph.id, node.id, 'testnum')
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
if (!state) throw new Error('Expected widget state to exist')
const widgetValue = computed(() => state.value)
@@ -211,105 +210,32 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
const interiorInput = interiorNode.addInput('value', 'STRING')
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
interiorInput.widget = { name: 'prompt' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
'value'
)
// Host the promoted view on a regular node so we can control widgets
// directly (SubgraphNode.widgets is a synthetic getter).
const graph = new LGraph()
const hostNode = new LGraphNode('host')
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
const graph = subgraphNode.graph
if (!graph) throw new Error('Expected subgraph node graph')
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const promotedViews = subgraphNode.widgets
const secondPromotedView = promotedViews[1]
if (!secondPromotedView) throw new Error('Expected second promoted view')
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceNodeId = '9999'
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceWidgetName = 'stale_widget'
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const secondMappedWidget = nodeData?.widgets?.find(
(widget) => widget.slotMetadata?.index === 1
)
if (!secondMappedWidget)
throw new Error('Expected mapped widget for slot 1')
expect(secondMappedWidget.name).not.toBe('stale_widget')
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
@@ -448,8 +374,8 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.entityId).toBe(
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
})
@@ -484,13 +410,13 @@ describe('Nested promoted widget mapping', () => {
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
expect(widgets?.[1]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
})
})
@@ -528,10 +454,11 @@ describe('Promoted widget sourceExecutionId', () => {
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_name'
(w) => w.name === 'ckpt_input'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(

View File

@@ -3,17 +3,16 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
inputForWidget,
promotedInputSource,
promotedInputWidgets
} from '@/core/graph/subgraph/promotedInputWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -27,10 +26,11 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { WidgetId } from '@/world/entityIds'
import type {
LGraph,
@@ -38,7 +38,8 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
LGraphTriggerParam,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -60,7 +61,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
entityId?: WidgetEntityId
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
@@ -81,17 +82,12 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
@@ -99,10 +95,14 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
* widget name, which can differ from the slot name (e.g. after a rename).
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -143,18 +143,6 @@ export interface GraphNodeManager {
cleanup(): void
}
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
if (!isPromotedWidgetView(widget)) return false
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
if (!sourceWidget) return false
const innerWidget = sourceWidget.widget
return (
('element' in innerWidget && !!innerWidget.element) ||
('component' in innerWidget && !!innerWidget.component)
)
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
@@ -214,73 +202,78 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: string
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const input = inputForWidget(node, widget)
if (!input?.widgetId) return undefined
const source = promotedInputSource(node, input)
if (!source) return undefined
const resolution = resolveConcretePromotedWidget(
node,
source.nodeId,
source.widgetName
)
const resolved =
resolution.status === 'resolved' ? resolution.resolved : undefined
const sourceWidget = resolved?.widget
const sourceNode = resolved?.node
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
return {
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
sourceWidgetName: sourceWidget?.name
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
displayName: widget.name,
promotedSource: null
}
}
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource: PromotedWidgetSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
: directSource
return {
displayName,
promotedSource
}
}
return function (widget) {
try {
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
@@ -294,67 +287,26 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const isPromotedPseudoWidget =
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const sourceWidgetName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = sourceWidgetName ?? displayName
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
return {
entityId: getWidgetEntityIdForNode(node, widget),
nodeId,
name,
type: effectiveWidget.type,
...sharedEnhancements,
widgetId: getWidgetIdForNode(node, widget),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
@@ -370,6 +322,24 @@ function safeWidgetMapper(
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
@@ -471,14 +441,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
@@ -534,7 +506,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
widget.slotMetadata = slotMetadata.get(widget.name)
}
}
@@ -812,7 +784,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so promotedLabel reflects the rename
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -1,7 +1,6 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
LGraphGroup,
LGraphNode,
@@ -265,16 +264,8 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const [widgetName, nodeId] = hoveredWidget.value ?? []
const widget =
nodeId !== undefined
? node?.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
: node?.widgets?.find((w) => w.name === widgetName)
const [widgetName] = hoveredWidget.value ?? []
const widget = node?.widgets?.find((w) => w.name === widgetName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)

View File

@@ -1,11 +1,21 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
const getNodeDisplayPrice = vi.fn(
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
String(overrides?.get('prompt') ?? 'missing override')
)
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({ getNodeDisplayPrice })
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
@@ -54,4 +64,43 @@ describe('subgraph pricing', () => {
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
subgraphTest(
'uses promoted widget override from any matching internal link',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
const apiNode = new ApiNode('api node')
apiNode.badges = [getCreditsBadge('$0.05/Run')]
const apiInput = apiNode.addInput('prompt', 'STRING')
apiInput.widget = { name: 'prompt' }
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
const decoyNode = new LGraphNode('decoy node')
const decoyInput = decoyNode.addInput('prompt', 'STRING')
decoyInput.widget = { name: 'prompt' }
decoyNode.addWidget(
'string',
'prompt',
'decoy value',
() => undefined,
{}
)
subgraph.add(decoyNode)
subgraph.add(apiNode)
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
subgraphNode._internalConfigureAfterSlots()
const inputWidgetId = subgraphNode.inputs[0].widgetId
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
updateSubgraphCredits(subgraphNode)
expect(getBadgeText(subgraphNode)).toBe('outer value')
}
)
})

View File

@@ -2,9 +2,14 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: { linkIds?: number[] }
}
const componentIconSvg = new Image()
componentIconSvg.src =
@@ -95,11 +100,20 @@ export const usePriceBadge = () => {
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
if (!input.widgetId) continue
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
const link = wrapper.subgraph.getLink(linkId)
if (link?.target_id !== innerNode.id) continue
const targetInput = innerNode.inputs[link.target_slot]
const widgetName = targetInput?.widget?.name
if (!widgetName) continue
overrides.set(
widgetName,
useWidgetValueStore().getWidget(input.widgetId)?.value
)
}
}
return overrides
}

View File

@@ -1,5 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -21,8 +20,8 @@ import {
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
@@ -55,39 +54,15 @@ function addInnerNode(
return node
}
function addPromotedHostInput(
function getPromotedInputValue(
host: SubgraphNode,
args: {
inputName: string
promotedName: string
sourceNodeId: string
sourceWidgetName: string
initialValue?: TWidgetValue
}
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
let widgetValue: TWidgetValue = args.initialValue ?? 0
const slot = host.addInput(args.inputName, '*')
slot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: args.promotedName,
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
},
hydrateHostValue(v: TWidgetValue) {
widgetValue = v
}
})
return {
setValue: (v) => {
widgetValue = v
},
getValue: () => widgetValue
}
name: string
): TWidgetValue | undefined {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) return undefined
return useWidgetValueStore().getWidget(input.widgetId)?.value as
| TWidgetValue
| undefined
}
function addPrimitiveWithTargets(
@@ -141,29 +116,6 @@ describe('flushProxyWidgetMigration', () => {
})
describe('value-widget repair', () => {
it('alreadyLinked: applies host value to the matching promoted widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 0
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(handle.getValue()).toBe(99)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
@@ -183,23 +135,23 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [99]
})
expect(host.widgets[0].value).toBe(99)
expect(getPromotedInputValue(host, 'seed')).toBe(99)
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
expect(innerWidget.value).toBe(0)
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const host = buildHost()
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 7
const slot = n.addInput('seed', 'INT')
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
slot.widget = { name: innerWidget.name }
})
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
const sparse: unknown[] = []
@@ -208,43 +160,7 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: sparse
})
expect(handle.getValue()).toBe(7)
})
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const a = addPromotedHostInput(host, {
inputName: 'first_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 1
})
const b = addPromotedHostInput(host, {
inputName: 'second_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 2
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(a.getValue()).toBe(1)
expect(b.getValue()).toBe(2)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'ambiguousSubgraphInput'
})
])
expect(getPromotedInputValue(host, 'seed')).toBe(7)
})
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
@@ -264,31 +180,6 @@ describe('flushProxyWidgetMigration', () => {
expect(created?._widget).toBeDefined()
})
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
const host = buildHost()
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
const slot1 = n.addInput('text', 'STRING')
slot1.widget = { name: 'text' }
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
const slot2 = n.addInput('text_1', 'STRING')
slot2.widget = { name: 'text_1' }
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
})
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
flushProxyWidgetMigration({ hostNode: host })
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
const linkedSlot = inner.inputs.find(
(slot) => slot.link === created?.linkIds[0]
)
expect(linkedSlot?.name).toBe('text_1')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
@@ -361,8 +252,7 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [123]
})
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(123)
expect(getPromotedInputValue(host, 'value')).toBe(123)
})
it('seeds value from the primitive widget when no host value is supplied', () => {
@@ -375,8 +265,7 @@ describe('flushProxyWidgetMigration', () => {
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(11)
expect(getPromotedInputValue(host, 'value')).toBe(11)
})
it('quarantines an unlinked primitive node with no fan-out', () => {
@@ -474,10 +363,8 @@ describe('flushProxyWidgetMigration', () => {
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
const widgetA = hostA.inputs.at(-1)?._widget
const widgetB = hostB.inputs.at(-1)?._widget
expect(widgetA?.value).toBe(11)
expect(widgetB?.value).toBe(22)
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
})
})

View File

@@ -1,6 +1,6 @@
import { isEqual } from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
findHostInputForPromotion,
@@ -8,6 +8,7 @@ import {
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
@@ -27,6 +28,7 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
@@ -90,26 +92,20 @@ export function normalizeLegacyProxyWidgetEntry(
function resolveSourceWidget(
sourceNode: LGraphNode,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
sourceWidgetName: string
): IBaseWidget | undefined {
const widgets = sourceNode.widgets
if (widgets && disambiguatingSourceNodeId !== undefined) {
const byDisambiguator = widgets.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceNodeId === disambiguatingSourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
if (byDisambiguator) return byDisambiguator
// Disambiguator missed: fall back only to non-promoted same-name widgets.
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
const byName = widgets.find(
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
)
if (byName) return byName
if (sourceNode.isSubgraphNode()) {
const input = sourceNode.inputs.find((input) => {
if (input.name === sourceWidgetName) return true
const target = resolveSubgraphInputTarget(sourceNode, input.name)
return target?.widgetName === sourceWidgetName
})
// Store-backed projection for a promoted input on a nested subgraph node:
// getSlotFromWidget locates the backing slot by widgetId.
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
}
const widgets = sourceNode.widgets
return (
widgets?.find((w) => w.name === sourceWidgetName) ??
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
@@ -300,19 +296,6 @@ function classify(
normalized.sourceWidgetName
)
if (linkedInput) {
const ambiguous =
hostNode.inputs.filter((input) => {
const w = input._widget
return (
!!w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === normalized.sourceNodeId &&
w.sourceWidgetName === normalized.sourceWidgetName
)
}).length > 1
if (ambiguous) {
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
@@ -350,8 +333,7 @@ function classify(
const sourceWidget = resolveSourceWidget(
sourceNode,
normalized.sourceWidgetName,
normalized.disambiguatingSourceNodeId
normalized.sourceWidgetName
)
if (!sourceWidget) {
return { kind: 'quarantine', reason: 'missingSourceWidget' }
@@ -373,19 +355,12 @@ function classify(
}
}
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
if (entry.isHole) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(entry.hostValue)
return
}
console.error(
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
{ widgetName: widget.name, type: widget.type }
)
function applyHostValueToInput(
input: INodeInputSlot,
entry: PendingEntry
): boolean {
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
}
function addUniqueSubgraphInput(
@@ -422,10 +397,9 @@ function repairAlreadyLinked(
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
const hostInput = matches[0]
if (!hostInput._widget) {
if (!applyHostValueToInput(hostInput, entry)) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: hostInput.name }
}
@@ -442,11 +416,7 @@ function repairCreateSubgraphInput(
return { ok: false, reason: 'missingSourceNode' }
}
const sourceWidget = resolveSourceWidget(
sourceNode,
sourceWidgetName,
entry.normalized.disambiguatingSourceNodeId
)
const sourceWidget = resolveSourceWidget(sourceNode, sourceWidgetName)
if (!sourceWidget) {
return { ok: false, reason: 'missingSourceWidget' }
}
@@ -480,11 +450,7 @@ function repairCreateSubgraphInput(
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (!hostInput?._widget) {
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
applyHostValue(hostInput._widget, entry)
if (hostInput) applyHostValueToInput(hostInput, entry)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
@@ -649,22 +615,19 @@ function repairPrimitive(
return failPrimitive('mutation failed; rolled back', { error: e })
}
// Apply through the host's input mirror (PromotedWidgetView), NOT
// `newSubgraphInput._widget`: the interior is shared across hosts.
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
const hostInputWidget = hostInput?._widget
if (hostInputWidget) {
if (hostInput) {
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
if (valueEntry) {
applyHostValue(hostInputWidget, valueEntry)
applyHostValueToInput(hostInput, valueEntry)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined) {
applyHostValue(hostInputWidget, {
applyHostValueToInput(hostInput, {
...validated.uniqueEntries[0],
hostValue: primitiveValue,
isHole: false

View File

@@ -0,0 +1,97 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
/**
* Where a promoted subgraph input is sourced from inside the subgraph. The
* interior node id + widget name that the host input slot forwards to. Resolved
* by walking the live link, so it is authoritative derived data — never stored
* on the projected widget.
*/
export interface PromotedSource {
nodeId: string
widgetName: string
}
/**
* The interior source of a host input slot, or undefined when the slot is not a
* promoted widget input.
*/
export function promotedInputSource(
node: LGraphNode,
input: INodeInputSlot
): PromotedSource | undefined {
if (!input.widgetId) return undefined
return resolveSubgraphInputTarget(node, input.name)
}
/** The host input slot backing a projected widget, matched by widgetId. */
export function inputForWidget(
node: LGraphNode,
widget: IBaseWidget
): INodeInputSlot | undefined {
return node.getSlotFromWidget(widget)
}
/**
* The interior source of a widget when it is a promoted subgraph input.
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
* host node is a subgraph node and its backing input slot has an interior
* source.
*/
export function widgetPromotedSource(
node: LGraphNode,
widget: IBaseWidget
): PromotedSource | undefined {
if (!node.isSubgraphNode()) return undefined
const input = inputForWidget(node, widget)
if (!input) return undefined
return promotedInputSource(node, input)
}
/**
* Projects a promoted subgraph input into an ordinary widget descriptor. The
* descriptor is store-backed: type/value/options read live from
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
* list does not reactively rebuild — and re-key — on every value edit.
*
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
* `label` is the mutable display label. Returns null when the input is not a
* promoted widget input.
*/
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
const id = input.widgetId
if (!id) return null
const store = useWidgetValueStore()
return {
name: input.name,
label: input.label ?? input.name,
y: 0,
widgetId: id,
get type() {
return store.getWidget(id)?.type ?? 'text'
},
get options() {
return store.getWidget(id)?.options ?? {}
},
get value() {
const value = store.getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
},
set value(next) {
store.setValue(id, next)
}
}
}
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
return node.inputs.flatMap((input) => {
const widget = promotedInputWidget(input)
return widget ? [widget] : []
})
}

View File

@@ -1,31 +1,17 @@
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 type { WidgetEntityId } from '@/world/entityIds'
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
/**
* A persisted promotion's source identity: the interior node + widget a host
* subgraph input was promoted from. Used by the migration/schema layer, where
* the source is a stored tuple rather than something link-derivable.
*/
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly entityId: WidgetEntityId
readonly sourceNodeId: string
readonly sourceWidgetName: string
hydrateHostValue(value: IBaseWidget['value']): void
ensureHostWidgetState(): void
}
export function isPromotedWidgetView(
widget: IBaseWidget
): widget is PromotedWidgetView {
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
}

View File

@@ -1,100 +0,0 @@
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 {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { isPromotedWidgetView } from './promotedWidgetTypes'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNumericInteriorNode(initialValue: number) {
const node = new LGraphNode('Interior')
const input = node.addInput('value', 'number')
node.addOutput('out', 'number')
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget }
}
describe('PromotedWidgetView — host-wins semantics', () => {
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior, widget: interiorWidget } =
createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
const viewA = hostA.widgets.find(isPromotedWidgetView)
const viewB = hostB.widgets.find(isPromotedWidgetView)
if (!viewA || !viewB)
throw new Error('Expected promoted views on both hosts')
viewA.value = 7
expect(viewA.value).toBe(7)
expect(interiorWidget.value).toBe(42)
expect(viewB.value).toBe(42)
})
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior } = createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(subgraph.rootGraph.id, {
nodeId: String(interior.id),
name: 'widget',
type: 'number',
value: 42,
options: {},
label: undefined,
serialize: true,
disabled: false
})
const host = createTestSubgraphNode(subgraph, { id: 200 })
const view = host.widgets.find(isPromotedWidgetView)
if (!view) throw new Error('Expected promoted view on host')
view.value = 99
const interiorState = widgetStore.getWidget(
subgraph.rootGraph.id,
String(interior.id),
'widget'
)
expect(interiorState?.value).toBe(42)
})
})

View File

@@ -1,614 +0,0 @@
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
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 { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
return 'mouse' in widget && typeof widget.mouse === 'function'
}
const designTokenCache = new Map<string, string>()
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
identityName
)
}
class PromotedWidgetView implements IPromotedWidgetView {
[symbol: symbol]: boolean
readonly sourceNodeId: string
readonly sourceWidgetName: string
readonly serialize = false
last_y?: number
computedHeight?: number
private readonly graphId: string
private yValue = 0
private _computedDisabled = false
private projectedSourceNode?: LGraphNode
private projectedSourceWidget?: IBaseWidget
private projectedSourceWidgetType?: IBaseWidget['type']
private projectedWidget?: BaseWidget
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
private _lastAutoSeededValue?: IBaseWidget['value']
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
}
get node(): SubgraphNode {
return this.subgraphNode
}
get name(): string {
return this.identityName ?? this.sourceWidgetName
}
get entityId(): WidgetEntityId {
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
}
get y(): number {
return this.yValue
}
set y(value: number) {
this.yValue = value
this.syncDomOverride()
}
get computedDisabled(): boolean {
return this._computedDisabled
}
set computedDisabled(value: boolean | undefined) {
this._computedDisabled = value ?? false
}
get type(): IBaseWidget['type'] {
return this.resolveDeepest()?.widget.type ?? 'button'
}
get options(): IBaseWidget['options'] {
return this.resolveDeepest()?.widget.options ?? {}
}
get tooltip(): string | undefined {
return this.resolveDeepest()?.widget.tooltip
}
get linkedWidgets(): IBaseWidget[] | undefined {
return this.resolveDeepest()?.widget.linkedWidgets
}
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)
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
}
private setHostWidgetState(value: IBaseWidget['value']): void {
if (!isWidgetValue(value)) return
const state = this.getHostWidgetState()
if (state) {
state.value = value
this._lastAutoSeededValue = undefined
return
}
this.registerHostWidgetState(value)
this._lastAutoSeededValue = undefined
}
ensureHostWidgetState(): void {
const fallback = this.fallbackEffectiveValue()
const existing = this.getHostWidgetState()
if (existing) {
if (
this._lastAutoSeededValue !== undefined &&
existing.value === this._lastAutoSeededValue &&
isWidgetValue(fallback) &&
fallback !== existing.value
) {
existing.value = fallback
this._lastAutoSeededValue = fallback
}
return
}
this.registerHostWidgetState(fallback)
this._lastAutoSeededValue = fallback
}
private fallbackEffectiveValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
label: this.displayName,
serialize: this.serialize,
disabled: this.computedDisabled
})
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
const state = this.getWidgetState()
return state?.label ?? this.displayName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
const state = this.getWidgetState()
if (state) state.label = value
}
hydrateHostValue(value: IBaseWidget['value']): void {
this.setHostWidgetState(value)
}
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
const resolved = this.resolveDeepest()
const computeLayoutSize = resolved?.widget.computeLayoutSize
if (!computeLayoutSize) return undefined
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
}
get computeSize(): IBaseWidget['computeSize'] {
const resolved = this.resolveDeepest()
const computeSize = resolved?.widget.computeSize
if (!computeSize) return undefined
return (width?: number) => computeSize.call(resolved.widget, width)
}
draw(
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
widgetWidth: number,
y: number,
H: number,
lowQuality?: boolean
): void {
const resolved = this.resolveDeepest()
if (!resolved) {
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
return
}
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
const projected = this.getProjectedWidget(resolved)
if (!projected || typeof projected.drawWidget !== 'function') return
const originalY = projected.y
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
}
onPointerDown(
pointer: CanvasPointer,
_node: LGraphNode,
canvas: LGraphCanvas
): boolean {
const resolved = this.resolveAtHost()
if (!resolved) return false
const interior = resolved.widget
if (typeof interior.onPointerDown === 'function') {
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
if (handled) return true
}
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
if (concrete)
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
if (hasLegacyMouse(interior))
return this.handleLegacyMouse(pointer, interior)
return false
}
callback(
value: unknown,
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,
e?: CanvasPointerEvent
) {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
afterQueued({
isPartialExecution
}: { isPartialExecution?: boolean } = {}): void {
this.applyValueControlToHost(isPartialExecution)
}
private applyValueControlToHost(isPartialExecution?: boolean): void {
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
const resolved = this.resolveAtHost()
const next = nextValueForLinkedTarget({
target: this,
linkedWidgets: resolved?.widget.linkedWidgets,
nodeId: this.subgraphNode.id,
isPartialExecution
})
if (next === undefined) return
this.hydrateHostValue(next)
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
}
private resolveDeepest():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
if (frame !== undefined && this.cachedDeepestFrame === frame)
return this.cachedDeepestByFrame
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
if (frame !== undefined) {
this.cachedDeepestFrame = frame
this.cachedDeepestByFrame = resolved
}
return resolved
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
const boundWidget = input._widget
if (boundWidget === this) return true
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
)
}
return input._subgraphSlot
.getConnectedWidgets()
.filter(hasWidgetNode)
.some(
(widget) =>
String(widget.node.id) === this.sourceNodeId &&
widget.name === this.sourceWidgetName
)
})
const linkedInput = linkedInputSlot?._subgraphSlot
if (!linkedInput) return []
return linkedInput
.getConnectedWidgets()
.filter(hasWidgetNode)
.map((widget) => ({
nodeId: stripGraphPrefix(String(widget.node.id)),
widgetName: widget.name,
widget
}))
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.filter((state): state is WidgetState => state !== undefined)
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget
}): BaseWidget | undefined {
const shouldRebuild =
!this.projectedWidget ||
this.projectedSourceNode !== resolved.node ||
this.projectedSourceWidget !== resolved.widget ||
this.projectedSourceWidgetType !== resolved.widget.type
if (!shouldRebuild) return this.projectedWidget
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
if (!concrete) {
this.projectedWidget = undefined
this.projectedSourceNode = undefined
this.projectedSourceWidget = undefined
this.projectedSourceWidgetType = undefined
return undefined
}
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
this.projectedSourceNode = resolved.node
this.projectedSourceWidget = resolved.widget
this.projectedSourceWidgetType = resolved.widget.type
return this.projectedWidget
}
private bindConcretePointerHandlers(
pointer: CanvasPointer,
canvas: LGraphCanvas,
concrete: BaseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
pointer.onClick = () =>
concrete.onClick({
e: downEvent,
node: this.subgraphNode,
canvas
})
pointer.onDrag = (eMove) =>
concrete.onDrag?.({
e: eMove,
node: this.subgraphNode,
canvas
})
return true
}
private handleLegacyMouse(
pointer: CanvasPointer,
interior: LegacyMouseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
const downPosition: Point = [
downEvent.canvasX - this.subgraphNode.pos[0],
downEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(downEvent, downPosition, this.subgraphNode)
pointer.finally = () => {
const upEvent = pointer.eUp
if (!upEvent) return
const upPosition: Point = [
upEvent.canvasX - this.subgraphNode.pos[0],
upEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(upEvent, upPosition, this.subgraphNode)
}
return true
}
private syncDomOverride(
resolved:
| { node: LGraphNode; widget: IBaseWidget }
| undefined = this.resolveAtHost()
) {
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
node: this.subgraphNode,
widget: this
})
}
}
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {
return 'id' in widget && ('element' in widget || 'component' in widget)
}
function drawDisconnectedPlaceholder(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
H: number
) {
const backgroundColor = readDesignToken(
'--color-secondary-background',
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-2xs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()
ctx.fillStyle = backgroundColor
ctx.fillRect(15, y, width - 30, H)
ctx.fillStyle = textColor
ctx.font = `${fontSize} ${fontFamily}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
ctx.restore()
}
function readDesignToken(token: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const cachedValue = designTokenCache.get(token)
if (cachedValue) return cachedValue
const value = getComputedStyle(document.documentElement)
.getPropertyValue(token)
.trim()
const resolvedValue = value || fallback
designTokenCache.set(token, resolvedValue)
return resolvedValue
}

View File

@@ -3,22 +3,42 @@ import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/world/entityIds'
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
function promotedInputNames(host: {
inputs: Array<{ widgetId?: unknown; name: string }>
}) {
return host.inputs
.filter((input) => input.widgetId)
.map((input) => input.name)
}
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
function writePromotedInputValue(
host: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
name: string,
value: IBaseWidget['value']
) {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
useWidgetValueStore().setValue(input.widgetId, value)
}
function promotedWidgetRef(host: SubgraphNode, name: string): IBaseWidget {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
const widget = promotedInputWidget(input)
if (!widget) throw new Error(`Missing promoted input ${name}`)
return widget
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
@@ -31,11 +51,9 @@ import {
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
getWidgetName,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected,
@@ -168,15 +186,18 @@ describe('pruneDisconnected', () => {
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
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 keptWidgetId = subgraphNode.inputs.find(
(input) => input.name === 'kept'
)?.widgetId
if (!keptWidgetId) throw new Error('Missing kept widgetId')
for (const input of [missingWidgetInput, missingNodeInput]) {
const hostInput = subgraphNode.inputs.find(
(entry) => entry._subgraphSlot === input
)
if (!hostInput) throw new Error(`Missing host input ${input.name}`)
hostInput.widgetId = keptWidgetId
}
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -485,79 +506,45 @@ describe('isLinkedPromotion', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedWidget(
sourceNodeId: string,
sourceWidgetName: string,
extra: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: 'value',
type: 'text',
value: '',
options: {},
y: 0,
...extra
} as unknown as IBaseWidget
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
const node = new LGraphNode('Source')
const input = node.addInput(widgetName, 'STRING')
const widget = node.addWidget('text', widgetName, '', () => {})
input.widget = { name: widget.name }
host.subgraph.add(node)
promoteValueWidgetViaSubgraphInput(host, node, widget)
return node
}
function createSubgraphWithInputs(count = 1) {
const subgraph = createTestSubgraph({
inputs: Array.from({ length: count }, (_, i) => ({
name: `input_${i}`,
type: 'STRING' as const
}))
})
return createTestSubgraphNode(subgraph)
}
it('returns true for a linked promotion', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
it('returns true when an input has a matching _widget', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
})
it('returns false when no inputs exist or none match', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
it('returns false when no promotion exists', () => {
const host = createTestSubgraphNode(createTestSubgraph())
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
})
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
it('returns false when sourceWidgetName does not match', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
})
it('returns false when _widget is undefined on input', () => {
const subgraphNode = createSubgraphWithInputs()
it('identifies linked widgets across different inputs', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const nodeA = promoteSource(host, 'string_a')
const nodeB = promoteSource(host, 'value')
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
})
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
disambiguatingSourceNodeId: '1'
})
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
})
it('identifies multiple linked widgets across different inputs', () => {
const subgraphNode = createSubgraphWithInputs(2)
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
})
})
@@ -607,17 +594,13 @@ describe('reorderSubgraphInputsByName', () => {
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
expect(host.widgets.map((widget) => widget.name)).toEqual([
'first',
'second'
])
expect(host.widgets).toHaveLength(0)
expect(promotedInputNames(host)).toEqual(['first', 'second'])
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.widgets).toHaveLength(0)
expect(promotedInputNames(host)).toEqual(['second', 'first'])
})
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
@@ -637,15 +620,13 @@ describe('reorderSubgraphInputsByName', () => {
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
subgraph.addInput('plain', 'STRING')
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
writePromotedInputValue(host, 'first', 'first value')
writePromotedInputValue(host, 'second', 'second value')
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.widgets).toHaveLength(0)
expect(promotedInputNames(host)).toEqual(['second', 'first'])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
@@ -727,14 +708,20 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
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'
writePromotedInputValue(host, 'text', 'first value')
writePromotedInputValue(host, 'text_1', 'second value')
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
const firstPromotedWidget = promotedWidgetRef(host, 'text')
const secondPromotedWidget = promotedWidgetRef(host, 'text_1')
reorderSubgraphInputsByWidgetOrder(host, [
secondPromotedWidget,
firstPromotedWidget
])
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
expect(host.widgets).toHaveLength(0)
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'text_1',
'text'
])
expect(host.serialize().widgets_values).toEqual([
'second value',
@@ -775,10 +762,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
hostInput.link = 9999
const promotedViewsBefore = host.widgets.length
const promotedInputId = hostInput.widgetId
expect(host.subgraph.inputs).toHaveLength(1)
expect(promotedViewsBefore).toBeGreaterThan(0)
expect(promotedInputId).toBeDefined()
demoteWidget(interiorNode, interiorWidget, [host])
@@ -788,13 +775,9 @@ describe('demoteWidget — axiomatic projection retraction', () => {
expect(
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
).toBe(false)
expect(
host.widgets.some(
(widget) =>
widgetSourceNodeId(widget) === String(interiorNode.id) &&
widget.name === interiorWidget.name
)
).toBe(false)
expect(host.widgets).toHaveLength(0)
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
})
it('removes the slot entirely when host slot has no external link', () => {
@@ -812,12 +795,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
const { host, nodeA, widgetA, nodeB, widgetB } =
buildDuplicateNamePromotion()
const promotedViewForB = host.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(promotedViewForB!.name).toBe('text_1')
demoteWidget(nodeB, promotedViewForB!, [host])
demoteWidget(nodeB, widgetB, [host])
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
@@ -825,15 +803,19 @@ describe('demoteWidget — axiomatic projection retraction', () => {
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
const { host: innerHost } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
for (const input of innerHost.inputs) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, input.name)
).ok
).toBe(true)
}
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
@@ -841,12 +823,7 @@ describe('demoteWidget — axiomatic projection retraction', () => {
'text_1'
])
const innerViewForB = innerHost.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(innerViewForB!.name).toBe('text_1')
demoteWidget(innerHost, innerViewForB!, [outerHost])
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
@@ -863,66 +840,19 @@ describe('disambiguated nested promotion identity', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedView(
sourceNodeId: string,
sourceWidgetName: string,
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: sourceWidgetName,
type: 'text',
value: '',
options: {},
y: 0,
...overrides
} as unknown as IBaseWidget
}
function createSubgraphHost() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
return createTestSubgraphNode(subgraph)
}
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
const host = createSubgraphHost()
host.inputs[0]._widget = linkedView('inner', 'text_1')
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
const interiorNode = {
id: 'inner',
title: 'inner',
type: 'inner'
} as unknown as LGraphNode
const source = {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(interiorWidget)
}
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
true
)
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
const { host: innerHost } = buildDuplicateNamePromotion()
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
subgraph.add(innerHost)
const nestedSubgraphNode = {
id: 'inner',
title: 'inner',
type: 'inner',
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
} as unknown as LGraphNode
subgraph.add(nestedSubgraphNode)
host.inputs[0]._widget = linkedView('inner', 'text_1')
expect(
promoteValueWidgetViaSubgraphInput(
host,
innerHost,
promotedWidgetRef(innerHost, 'text_1')
).ok
).toBe(true)
pruneDisconnected(host)
@@ -956,9 +886,13 @@ describe('disambiguated nested promotion identity', () => {
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
for (const input of innerHost.inputs) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, input.name)
).ok
).toBe(true)
}

View File

@@ -1,6 +1,6 @@
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -18,7 +18,9 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { readWidgetValue } from '@/world/widgetValueIO'
import { widgetId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
@@ -46,16 +48,47 @@ export function findHostInputForPromotion(
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
const w = input._widget
const source = input._subgraphSlot
? resolvePromotionSource(subgraphNode, input._subgraphSlot)
: undefined
return (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === sourceNodeId &&
w.sourceWidgetName === sourceWidgetName
source?.sourceNodeId === sourceNodeId &&
source.sourceWidgetName === sourceWidgetName
)
})
}
function resolvePromotionSource(
subgraphNode: SubgraphNode,
subgraphInput: { linkIds: readonly number[] }
): PromotedWidgetSource | undefined {
for (const linkId of subgraphInput.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraphNode.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
if (!targetInput) continue
if (inputNode.isSubgraphNode()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.name
}
}
}
export function reorderSubgraphInputsByName(
subgraphNode: SubgraphNode,
orderedInputNames: readonly string[]
@@ -78,13 +111,12 @@ export function reorderSubgraphInputsByName(
export function reorderSubgraphInputsByWidgetOrder(
subgraphNode: SubgraphNode,
orderedWidgets: readonly IBaseWidget[]
orderedWidgets: readonly Pick<IBaseWidget, 'widgetId'>[]
): 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)) {
if (isSamePromotedInput(subgraphNode, index, orderedWidget)) {
remainingIndices.delete(index)
return [index]
}
@@ -101,37 +133,48 @@ function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const widgetValues = subgraphNode.inputs.map((input) =>
getExplicitHostWidgetValue(input?._widget)
)
const widgetValues = subgraphNode.inputs.map((input) => {
const id = input?.widgetId
if (!id) return undefined
const value = useWidgetValueStore().getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
if (value === undefined) continue
const widget = subgraphNode.inputs[newIndex]?._widget
if (widget) widget.value = value
const id = subgraphNode.inputs[newIndex]?.widgetId
if (value === undefined || !id) continue
useWidgetValueStore().setValue(id, value)
}
}
function getExplicitHostWidgetValue(
widget: IBaseWidget | undefined
): IBaseWidget['value'] | undefined {
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
function isSamePromotedInput(
subgraphNode: SubgraphNode,
inputIndex: number,
orderedWidget: Pick<IBaseWidget, 'widgetId'>
): boolean {
const input = subgraphNode.inputs[inputIndex]
const linkedInput = input?._subgraphSlot
if (!input || !linkedInput) return false
const value = readWidgetValue(widget.entityId)
return isWidgetValue(value) ? value : undefined
}
for (const linkId of linkedInput.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
const { inputNode, input: targetInput } = link.resolve(
subgraphNode.subgraph
)
if (!inputNode || !targetInput) continue
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (targetWidget === orderedWidget) return true
if (input.widgetId && input.widgetId === orderedWidget.widgetId) return true
}
return false
}
function isPreviewExposed(
@@ -168,13 +211,9 @@ function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
const widgetIsParentLevelView =
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
return {
sourceNodeId: String(node.id),
sourceWidgetName: widgetIsParentLevelView
? widget.sourceWidgetName
: getWidgetName(widget)
sourceWidgetName: getWidgetName(widget)
}
}
@@ -217,9 +256,41 @@ export function promoteValueWidgetViaSubgraphInput(
return { ok: false, reason: 'connectFailed' }
}
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
return { ok: true }
}
function seedNestedPromotedInputState(
subgraphNode: SubgraphNode,
inputName: string,
sourceSlot: { widgetId?: WidgetId }
): void {
if (!sourceSlot.widgetId) return
const hostInput = subgraphNode.inputs.find(
(input) => input._subgraphSlot?.name === inputName
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
}
function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
@@ -283,6 +354,32 @@ export function promoteWidget(
})
}
/**
* Removes the host input projecting a linked promotion identified by source.
* Returns true when an input was found and demoted.
*/
export function demotePromotedInput(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
if (!subgraphNode.subgraph) return false
const hostInput = findHostInputForPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (!linkedInput) return false
if (hostInput.link != null) {
linkedInput.disconnect()
} else {
subgraphNode.subgraph.removeInput(linkedInput)
}
return true
}
export function demoteWidget(
node: PartialNode,
widget: IBaseWidget,
@@ -292,21 +389,7 @@ export function demoteWidget(
for (const parent of parents) {
if (!parent.subgraph) continue
const hostInput = findHostInputForPromotion(
parent,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (linkedInput) {
const hasExternalLink = hostInput.link != null
if (hasExternalLink) {
linkedInput.disconnect()
} else {
parent.subgraph.removeInput(linkedInput)
}
continue
}
if (demotePromotedInput(parent, source)) continue
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
@@ -505,37 +588,19 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
const removedEntries: PromotedWidgetSource[] = []
const staleInputs = subgraph.inputs.filter((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
const source = resolvePromotionSource(subgraphNode, input)
if (source) return false
// If the SubgraphInput has any live link to an interior target slot that
// still has a widget, the promotion is alive — even when the widget's
// sourceNodeId points at a deeply-nested interior node that does not exist
// directly in `subgraph` (nested SubgraphNode promotions).
for (const linkId of input.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode) continue
const targetInputSlot = inputNode.inputs?.find(
(slot) => slot.link === linkId
)
if (!targetInputSlot) continue
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
}
const node = subgraph.getNodeById(widget.sourceNodeId)
if (!node) {
removedEntries.push(widget)
return true
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === widget.sourceWidgetName
const hostInput = subgraphNode.inputs.find(
(entry) => entry._subgraphSlot === input
)
if (!hasWidget) {
removedEntries.push(widget)
}
return !hasWidget
if (!hostInput?.widgetId && !hostInput?._widget) return false
removedEntries.push({
sourceNodeId: String(subgraphNode.id),
sourceWidgetName: input.name
})
return true
})
for (const input of staleInputs) {

View File

@@ -2,10 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
@@ -24,15 +21,6 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
type PromotedWidgetStub = Pick<
IBaseWidget,
'name' | 'type' | 'options' | 'value' | 'y'
> & {
sourceNodeId: string
sourceWidgetName: string
node?: SubgraphNode
}
function createHostNode(id: number): SubgraphNode {
return createTestSubgraphNode(createTestSubgraph(), { id })
}
@@ -47,55 +35,10 @@ function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
return node.addWidget('text', name, `${name}-value`, () => undefined)
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
type: 'button',
options: {},
y: 0,
value: undefined,
sourceNodeId,
sourceWidgetName,
node
}
return promotedWidget as IBaseWidget
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('resolvePromotedWidgetAtHost', () => {
test('resolves a direct concrete widget on the host subgraph node', () => {
const host = createHostNode(100)
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const resolved = resolvePromotedWidgetAtHost(
host,
String(concreteNode.id),
'seed'
)
expect(resolved).toBeDefined()
expect(resolved?.node.id).toBe(concreteNode.id)
expect(resolved?.widget.name).toBe('seed')
})
test('returns undefined when host does not contain the target node', () => {
const host = createHostNode(100)
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
expect(resolved).toBeUndefined()
})
})
describe('resolveConcretePromotedWidget', () => {
test('resolves a direct concrete source widget', () => {
const host = createHostNode(100)
@@ -114,106 +57,41 @@ describe('resolveConcretePromotedWidget', () => {
expect(result.resolved.widget.name).toBe('seed')
})
test('descends through nested promoted widgets to resolve concrete source', () => {
const rootHost = createHostNode(100)
const nestedHost = createHostNode(101)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'seed')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
]
test('descends through nested subgraph inputs to the deepest concrete widget', () => {
const innerSubgraph = createTestSubgraph({
inputs: [{ name: 'x', type: '*' }]
})
const leaf = new LGraphNode('Leaf')
const leafInput = leaf.addInput('x', '*')
leaf.addWidget('combo', 'seed', 'a', () => undefined, {
values: ['a', 'b']
})
leafInput.widget = { name: 'seed' }
innerSubgraph.add(leaf)
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const innerNode = createTestSubgraphNode(innerSubgraph, { id: 11 })
const outerSubgraph = createTestSubgraph({
inputs: [{ name: 'y', type: '*' }]
})
outerSubgraph.add(innerNode)
innerNode._internalConfigureAfterSlots()
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const outerNode = createTestSubgraphNode(outerSubgraph, { id: 22 })
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'outer'
outerNode,
String(innerNode.id),
'x'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.node.id).toBe(leaf.id)
expect(result.resolved.widget.name).toBe('seed')
})
test('returns cycle failure when promoted widgets form a loop', () => {
const hostA = createHostNode(200)
const hostB = createHostNode(201)
const relayA = addNodeToHost(hostA, 'relayA')
const relayB = addNodeToHost(hostB, 'relayB')
relayA.widgets = [
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
]
relayB.widgets = [
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
]
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
expect(result).toEqual({
status: 'failure',
failure: 'cycle'
})
})
test('does not report a cycle when different host objects share an id', () => {
const rootHost = createHostNode(41)
const nestedHost = createHostNode(41)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'w')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
]
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'w'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('w')
})
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
const hosts = Array.from({ length: 102 }, (_, index) =>
createHostNode(index + 1)
)
const relayNodes = hosts.map((host, index) =>
addNodeToHost(host, `relay-${index}`)
)
for (let index = 0; index < relayNodes.length - 1; index += 1) {
relayNodes[index].widgets = [
createPromotedWidget(
`w-${index}`,
String(relayNodes[index + 1].id),
`w-${index + 1}`,
hosts[index + 1]
)
]
}
addConcreteWidget(
relayNodes[relayNodes.length - 1],
`w-${relayNodes.length - 1}`
)
const result = resolveConcretePromotedWidget(
hosts[0],
String(relayNodes[0].id),
'w-0'
)
expect(result).toEqual({
status: 'failure',
failure: 'max-depth-exceeded'
})
expect(result.resolved.widget.type).toBe('combo')
})
test('returns invalid-host for non-subgraph host node', () => {

View File

@@ -1,8 +1,7 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
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'
type PromotedWidgetResolutionFailure =
| 'invalid-host'
@@ -41,6 +40,17 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-node' }
}
if (sourceNode.isSubgraphNode()) {
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
if (!target) {
return { status: 'failure', failure: 'missing-widget' }
}
currentHost = sourceNode
currentNodeId = target.nodeId
currentWidgetName = target.widgetName
continue
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
)
@@ -48,39 +58,15 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-widget' }
}
if (!isPromotedWidgetView(sourceWidget)) {
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
}
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
}
if (!sourceWidget.node?.isSubgraphNode()) {
return { status: 'failure', failure: 'missing-node' }
}
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find((entry) => entry.name === widgetName)
if (!widget) return undefined
return { node, widget }
}
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
@@ -91,20 +77,3 @@ export function resolveConcretePromotedWidget(
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -1,8 +0,0 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function hasWidgetNode(
widget: IBaseWidget
): widget is IBaseWidget & { node: LGraphNode } {
return 'node' in widget && !!widget.node
}

View File

@@ -1,7 +1,5 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
let nextWidgetRenderKeyId = 0
@@ -9,9 +7,7 @@ export function getStableWidgetRenderKey(widget: IBaseWidget): string {
const cachedKey = widgetRenderKeys.get(widget)
if (cachedKey) return cachedKey
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
const key = `${prefix}:${nextWidgetRenderKeyId++}`
const key = `widget:${nextWidgetRenderKeyId++}`
widgetRenderKeys.set(widget, key)
return key
}

View File

@@ -24,6 +24,7 @@ import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
@@ -190,7 +191,9 @@ function dynamicComboWidget(
const getState = () => {
const graphId = resolveNodeRootGraphId(node)
if (!graphId) return undefined
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
return useWidgetValueStore().getWidget(
widgetId(graphId, node.id, widget.name)
)
}
Object.defineProperty(widget, 'value', {
get() {

View File

@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
import { widgetId } from '@/types/widgetId'
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
applyFirstWidgetValueToGraph(this, extraLinks)
@@ -51,16 +52,15 @@ function onCustomComboCreated(this: LGraphNode) {
Object.defineProperty(widget, 'value', {
get() {
return (
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
useWidgetValueStore().getWidget(
widgetId(app.rootGraph.id, node.id, widgetName)
)?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
app.rootGraph.id,
node.id,
widgetName
widgetId(app.rootGraph.id, node.id, widgetName)
)
if (state) state.value = v
updateCombo()

View File

@@ -20,6 +20,7 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { type NodeLocatorId } from '@/types'
import { widgetId } from '@/types/widgetId'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
@@ -152,16 +153,16 @@ app.registerExtension({
audioUIWidget.options.getValue = () =>
(useWidgetValueStore().getWidget(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
widgetId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)
)?.value as string) ?? ''
audioUIWidget.options.setValue = (v) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
graphId,
node.id,
inputName
widgetId(graphId, node.id, inputName)
)
if (widgetState) widgetState.value = v
}

View File

@@ -16,6 +16,7 @@ import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import {
createTestSubgraph,
createTestSubgraphData,
@@ -296,9 +297,8 @@ describe('Graph Clearing and Callbacks', () => {
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(graphId, {
nodeId: '10' as NodeId,
name: 'seed',
const seedWidgetId = widgetId(graphId, '10' as NodeId, 'seed')
widgetValueStore.registerWidget(seedWidgetId, {
type: 'number',
value: 1,
options: {},
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect(widgetValueStore.getWidget(seedWidgetId)).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
@@ -316,9 +316,7 @@ describe('Graph Clearing and Callbacks', () => {
graph.clear()
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(widgetValueStore.getWidget(seedWidgetId)).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)

View File

@@ -1,6 +1,6 @@
import type { Rectangle } from './infrastructure/Rectangle'
import type { CanvasColour } from './interfaces'
import { LiteGraph } from './litegraph'
import { litegraph } from './litegraphInstance'
import { RenderShape, TitleMode } from './types/globalEnums'
import { cachedMeasureText } from './utils/textMeasureCache'
@@ -81,12 +81,12 @@ export function strokeShape(
}: IDrawBoundingOptions = {}
): void {
// These param defaults are not compile-time static, and must be re-evaluated at runtime
round_radius ??= LiteGraph.ROUND_RADIUS
color ??= LiteGraph.NODE_BOX_OUTLINE_COLOR
round_radius ??= litegraph().ROUND_RADIUS
color ??= litegraph().NODE_BOX_OUTLINE_COLOR
// Adjust area if title is transparent
if (title_mode === TitleMode.TRANSPARENT_TITLE) {
const height = title_height ?? LiteGraph.NODE_TITLE_HEIGHT
const height = title_height ?? litegraph().NODE_TITLE_HEIGHT
area[1] -= height
area[3] += height
}

View File

@@ -1,5 +1,6 @@
import type { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { WidgetId } from '@/types/widgetId'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { ContextMenu } from './ContextMenu'
@@ -362,6 +363,7 @@ export interface IWidgetLocator {
export interface INodeInputSlot extends INodeSlot {
link: LinkId | null
widget?: IWidgetLocator
widgetId?: WidgetId
alwaysVisible?: boolean
/**

View File

@@ -7,6 +7,7 @@ import type {
Point,
Size
} from './interfaces'
import { registerLiteGraphInstance } from './litegraphInstance'
import { loadPolyfills } from './polyfills'
import type { CanvasEventDetail } from './types/events'
import type { RenderShape, TitleMode } from './types/globalEnums'
@@ -15,6 +16,7 @@ import type { RenderShape, TitleMode } from './types/globalEnums'
export { Subgraph } from './subgraph/Subgraph'
export const LiteGraph = new LiteGraphGlobal()
registerLiteGraphInstance(LiteGraph)
// Load legacy polyfills
loadPolyfills()

View File

@@ -0,0 +1,26 @@
import type { LiteGraphGlobal } from './LiteGraphGlobal'
/**
* Late-bound holder for the {@link LiteGraphGlobal} singleton.
*
* This module imports `LiteGraphGlobal` as a type only, so it has no runtime
* dependencies. Modules in the widget initialisation chain (e.g. `draw.ts`,
* imported transitively by `BaseWidget`) can read singleton constants through
* {@link litegraph} without importing the `litegraph` barrel — which would
* re-enter the barrel mid-initialisation and evaluate
* `LegacyWidget extends BaseWidget` before `BaseWidget` is defined.
*
* The barrel constructs the singleton and calls {@link registerLiteGraphInstance}.
*/
let instance: LiteGraphGlobal | null = null
export function registerLiteGraphInstance(value: LiteGraphGlobal): void {
instance = value
}
export function litegraph(): LiteGraphGlobal {
if (!instance) {
throw new Error('LiteGraph singleton accessed before initialisation')
}
return instance
}

View File

@@ -2,6 +2,7 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import { InvalidLinkError } from '@/lib/litegraph/src/infrastructure/InvalidLinkError'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
import { SlotIndexError } from '@/lib/litegraph/src/infrastructure/SlotIndexError'
@@ -183,15 +184,14 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Nothing connected
const linkId = subgraphNodeInput.link
if (linkId == null) {
const widget = subgraphNode.getWidgetFromSlot(subgraphNodeInput)
if (!widget) return
const id = subgraphNodeInput.widgetId
if (!id) return
// Special case: SubgraphNode widget.
return {
node: this,
origin_id: this.id,
origin_slot: -1,
widgetInfo: { value: widget.value }
widgetInfo: { value: useWidgetValueStore().getWidget(id)?.value }
}
}

View File

@@ -1,128 +0,0 @@
import { describe, expect, test } from 'vitest'
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
type TestPromotionEntry = {
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
}
function makeView(entry: TestPromotionEntry) {
const baseKey = `${entry.sourceNodeId}:${entry.sourceWidgetName}`
return {
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
}
}
describe('PromotedWidgetViewManager', () => {
test('returns memoized array when entries reference is unchanged', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const entries = [{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }]
const first = manager.reconcile(entries, makeView)
const second = manager.reconcile(entries, makeView)
expect(second).toBe(first)
expect(second[0]).toBe(first[0])
})
test('preserves view identity while reflecting order changes', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }
],
makeView
)
const reordered = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(reordered[0]).toBe(firstPass[1])
expect(reordered[1]).toBe(firstPass[0])
})
test('deduplicates by first occurrence and clears stale cache entries', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const first = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(first.map((view) => view.key)).toStrictEqual([
'1:widgetA',
'1:widgetB'
])
manager.reconcile(
[{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }],
makeView
)
const restored = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(restored[0]).toBe(first[1])
expect(restored[1]).not.toBe(first[0])
})
test('keeps distinct views for same source widget when viewKeys differ', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const views = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(views).toHaveLength(2)
expect(views[0]).not.toBe(views[1])
expect(views[0].key).toBe('1:widgetA:slotA')
expect(views[1].key).toBe('1:widgetA:slotB')
})
test('removeByViewKey removes only the targeted keyed view', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
manager.removeByViewKey('1', 'widgetA', 'slotA')
const secondPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(secondPass[0]).not.toBe(firstPass[0])
expect(secondPass[1]).toBe(firstPass[1])
})
})

View File

@@ -1,118 +0,0 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type ViewManagerEntry = PromotedWidgetSource & {
viewKey?: string
}
/**
* Reconciles promoted widget entries to stable view instances.
*
* Keeps object identity stable by key while preserving the current
* promotion order and deduplicating duplicate entries by first occurrence.
*/
export class PromotedWidgetViewManager<TView> {
private viewCache = new Map<string, TView>()
private cachedViews: TView[] | null = null
private cachedEntryKeys: string[] | null = null
reconcile<TEntry extends ViewManagerEntry>(
entries: readonly TEntry[],
createView: (entry: TEntry) => TView
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)
)
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
return this.cachedViews
const views: TView[] = []
const seenKeys = new Set<string>()
for (const entry of entries) {
const key = this.makeKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey
)
if (seenKeys.has(key)) continue
seenKeys.add(key)
const existing = this.viewCache.get(key)
if (existing) {
views.push(existing)
continue
}
const nextView = createView(entry)
this.viewCache.set(key, nextView)
views.push(nextView)
}
for (const key of this.viewCache.keys()) {
if (!seenKeys.has(key)) this.viewCache.delete(key)
}
this.cachedViews = views
this.cachedEntryKeys = entryKeys
return views
}
getOrCreate(
sourceNodeId: string,
sourceWidgetName: string,
createView: () => TView,
viewKey?: string
): TView {
const key = this.makeKey(sourceNodeId, sourceWidgetName, viewKey)
const cached = this.viewCache.get(key)
if (cached) return cached
const view = createView()
this.viewCache.set(key, view)
return view
}
remove(sourceNodeId: string, sourceWidgetName: string): void {
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName))
this.invalidateMemoizedList()
}
removeByViewKey(
sourceNodeId: string,
sourceWidgetName: string,
viewKey: string
): void {
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName, viewKey))
this.invalidateMemoizedList()
}
clear(): void {
this.viewCache.clear()
this.invalidateMemoizedList()
}
invalidateMemoizedList(): void {
this.cachedViews = null
this.cachedEntryKeys = null
}
private areEntryKeysEqual(entryKeys: string[]): boolean {
if (!this.cachedEntryKeys) return false
if (this.cachedEntryKeys.length !== entryKeys.length) return false
for (let index = 0; index < entryKeys.length; index += 1) {
if (this.cachedEntryKeys[index] !== entryKeys[index]) return false
}
return true
}
private makeKey(
sourceNodeId: string,
sourceWidgetName: string,
viewKey?: string
): string {
const baseKey = `${sourceNodeId}:${sourceWidgetName}`
return viewKey ? `${baseKey}:${viewKey}` : baseKey
}
}

View File

@@ -486,10 +486,6 @@ describe('SubgraphIO - Empty Slot Connection', () => {
'seed',
'seed_1'
])
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
'seed',
'seed_1'
])
}
)
})

View File

@@ -101,43 +101,6 @@ describe('SubgraphNode Memory Management', () => {
})
describe('Widget Promotion Memory Management', () => {
it('should not mutate manually injected widget references', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'testInput', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const input = subgraphNode.inputs[0]
const mockWidget = {
type: 'number',
name: 'promoted_widget',
value: 123,
options: {},
y: 0,
draw: vi.fn(),
mouse: vi.fn(),
computeSize: vi.fn(),
createCopyForNode: vi.fn().mockReturnValue({
type: 'number',
name: 'promoted_widget',
value: 123
})
} as Partial<IWidget> as IWidget
input._widget = mockWidget
input.widget = { name: 'promoted_widget' }
subgraphNode.widgets.push(mockWidget)
expect(input._widget).toBe(mockWidget)
expect(input.widget).toBeDefined()
expect(subgraphNode.widgets).toContain(mockWidget)
subgraphNode.removeWidget(mockWidget)
// removeWidget only affects managed promoted widgets, not manually injected entries.
expect(subgraphNode.widgets).toContain(mockWidget)
})
it('should not leak widgets during reconfiguration', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input1', type: 'number' }]

View File

@@ -5,13 +5,13 @@
* IO synchronization, and edge cases.
*/
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -199,6 +199,35 @@ describe('SubgraphNode Synchronization', () => {
expect(subgraphNode.outputs[0].label).toBe('newOutput')
})
it('represents promoted host widgets by input widgetId and WidgetState', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
})
const interiorNode = new LGraphNode('Interior')
const input = interiorNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
interiorNode.addOutput('out', 'STRING')
interiorNode.addWidget('text', 'value', 'initial', () => {})
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedInput = subgraphNode.inputs[0]
const inputWidgetId = promotedInput.widgetId
expect(subgraphNode.widgets).toHaveLength(0)
expect(promotedInput._widget).toBeUndefined()
expect(inputWidgetId).toBeDefined()
expect('sourceNodeId' in promotedInput).toBe(false)
expect('sourceWidgetName' in promotedInput).toBe(false)
if (!inputWidgetId) throw new Error('Missing widgetId')
expect(useWidgetValueStore().getWidget(inputWidgetId)?.value).toBe(
'initial'
)
})
it('should keep input.widget.name stable after rename (onGraphConfigured safety)', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text', type: 'STRING' }]
@@ -218,7 +247,6 @@ describe('SubgraphNode Synchronization', () => {
const originalWidgetName = promotedInput.widget!.name
// Rename the subgraph input label
subgraph.inputs[0].label = 'my_custom_prompt'
subgraph.events.dispatch('renaming-input', {
input: subgraph.inputs[0],
@@ -227,17 +255,14 @@ describe('SubgraphNode Synchronization', () => {
newName: 'my_custom_prompt'
})
// widget.name stays as the internal name — NOT the display label
expect(promotedInput.widget!.name).toBe(originalWidgetName)
// The display label is on input.label (live-read via PromotedWidgetView.label)
expect(promotedInput.label).toBe('my_custom_prompt')
// input.widget.name should still match a widget in node.widgets
const matchingWidget = subgraphNode.widgets?.find(
(w) => w.name === promotedInput.widget!.name
expect(subgraphNode.widgets).toHaveLength(0)
expect(promotedInput.widgetId).toBeDefined()
if (!promotedInput.widgetId) throw new Error('Missing widgetId')
expect(useWidgetValueStore().getWidget(promotedInput.widgetId)?.label).toBe(
'my_custom_prompt'
)
expect(matchingWidget).toBeDefined()
})
it('should preserve renamed label through serialize/configure round-trip', () => {
@@ -254,10 +279,10 @@ describe('SubgraphNode Synchronization', () => {
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph)
const promotedWidget = subgraphNode.widgets?.[0]
expect(promotedWidget).toBeDefined()
const inputSlot = subgraphNode.inputs[0]
expect(inputSlot.widgetId).toBeDefined()
if (!inputSlot.widgetId) throw new Error('Missing widgetId')
// Rename via the subgraph slot (simulates right-click rename)
subgraph.inputs[0].label = 'My Seed'
subgraphNode.inputs[0].label = 'My Seed'
subgraph.events.dispatch('renaming-input', {
@@ -267,20 +292,16 @@ describe('SubgraphNode Synchronization', () => {
newName: 'My Seed'
})
// Label should be visible before round-trip
const widgetBeforeRoundTrip = subgraphNode.widgets?.[0]
expect(widgetBeforeRoundTrip!.label || widgetBeforeRoundTrip!.name).toBe(
expect(useWidgetValueStore().getWidget(inputSlot.widgetId)?.label).toBe(
'My Seed'
)
// Serialize and reconfigure (simulates save/reload)
const serialized = subgraphNode.serialize()
subgraphNode.configure(serialized)
// Label should survive the round-trip
const widgetAfterRoundTrip = subgraphNode.widgets?.[0]
expect(widgetAfterRoundTrip).toBeDefined()
expect(widgetAfterRoundTrip!.label || widgetAfterRoundTrip!.name).toBe(
expect(subgraphNode.widgets).toHaveLength(0)
expect(inputSlot.label).toBe('My Seed')
expect(useWidgetValueStore().getWidget(inputSlot.widgetId)?.label).toBe(
'My Seed'
)
})
@@ -339,11 +360,10 @@ describe('SubgraphNode widget name collision on rename', () => {
// Display labels: input[1] was renamed
expect(subgraphNode.inputs[1].label).toBe('prompt_a')
// Distinct _widget bindings
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
expect(subgraphNode.inputs[0].widgetId).toBeDefined()
expect(subgraphNode.inputs[1].widgetId).toBeDefined()
expect(subgraphNode.inputs[0].widgetId).not.toBe(
subgraphNode.inputs[1].widgetId
)
})
@@ -394,11 +414,10 @@ describe('SubgraphNode widget name collision on rename', () => {
expect(subgraphNode.inputs[0].widget?.name).toBe(key0)
expect(subgraphNode.inputs[1].widget?.name).toBe(key1)
// Distinct _widget bindings survive the rename
expect(subgraphNode.inputs[0]._widget).toBeDefined()
expect(subgraphNode.inputs[1]._widget).toBeDefined()
expect(subgraphNode.inputs[0]._widget).not.toBe(
subgraphNode.inputs[1]._widget
expect(subgraphNode.inputs[0].widgetId).toBeDefined()
expect(subgraphNode.inputs[1].widgetId).toBeDefined()
expect(subgraphNode.inputs[0].widgetId).not.toBe(
subgraphNode.inputs[1].widgetId
)
})
@@ -437,17 +456,13 @@ describe('SubgraphNode widget name collision on rename', () => {
newName: 'alpha'
})
// Simulate onGraphConfigured check: for each input with widget,
// find a matching widget by name. If not found, the input gets removed.
for (const input of subgraphNode.inputs) {
if (!input.widget) continue
const name = input.widget.name
const w = subgraphNode.widgets?.find((w) => w.name === name)
// Every input should find at least one matching widget
expect(w).toBeDefined()
expect(input.widgetId).toBeDefined()
if (!input.widgetId) throw new Error('Missing widgetId')
expect(useWidgetValueStore().getWidget(input.widgetId)).toBeDefined()
}
// Both inputs should survive
expect(subgraphNode.widgets).toHaveLength(0)
expect(subgraphNode.inputs).toHaveLength(2)
})
})
@@ -929,41 +944,6 @@ describe('Nested SubgraphNode duplicate input prevention', () => {
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const nodeWithKeyBuilder = fromAny<
{
_makePromotionViewKey: (
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName?: string
) => string
},
unknown
>(subgraphNode)
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a:b',
'c'
)
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a',
'b:c'
)
expect(firstKey).not.toBe(secondKey)
})
})
describe('SubgraphNode label propagation', () => {
it('should preserve input labels from configure path', () => {
const subgraph = createTestSubgraph({
@@ -1008,15 +988,19 @@ describe('SubgraphNode label propagation', () => {
const labelChangedSpy = vi.spyOn(subgraphNode.graph!, 'trigger')
expect(promotedInput.label).toBeUndefined()
expect(promotedInput._widget).toBeDefined()
expect(promotedInput._widget).toBeUndefined()
expect(promotedInput.widgetId).toBeDefined()
if (!promotedInput.widgetId) throw new Error('Missing widgetId')
subgraph.renameInput(subgraph.inputs[0], 'Steps Count')
expect(promotedInput.label).toBe('Steps Count')
expect(promotedInput.name).toBe('steps')
expect(promotedInput.widget?.name).toBe(originalWidgetName)
expect(promotedInput._widget?.label).toBe('Steps Count')
expect(subgraphNode.widgets?.[0].label).toBe('Steps Count')
expect(useWidgetValueStore().getWidget(promotedInput.widgetId)?.label).toBe(
'Steps Count'
)
expect(subgraphNode.widgets).toHaveLength(0)
expect(labelChangedSpy).toHaveBeenCalledWith('node:slot-label:changed', {
nodeId: subgraphNode.id,
slotType: NodeSlotType.INPUT

View File

@@ -1,3 +1,4 @@
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import type { BaseLGraph, LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import type { LGraphButton } from '@/lib/litegraph/src/LGraphButton'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -33,23 +34,17 @@ import type {
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import {
createPromotedWidgetView,
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
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 { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { readWidgetValue } from '@/world/widgetValueIO'
import { widgetId } from '@/world/entityIds'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
import { PromotedWidgetViewManager } from './PromotedWidgetViewManager'
import type { SubgraphInput } from './SubgraphInput'
import { createBitmapCache } from './svgBitmapCache'
@@ -57,11 +52,6 @@ const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
slotName: string
}
const workflowBitmapCache = createBitmapCache(workflowSvg, 32)
export class SubgraphNode extends LGraphNode implements BaseLGraph {
@@ -85,179 +75,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return true
}
private _promotedViewManager =
new PromotedWidgetViewManager<PromotedWidgetView>()
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
entries: LinkedPromotionEntry[]
}
private _promotedViewsCache?: {
version: number
views: PromotedWidgetView[]
}
declare widgets: IBaseWidget[]
private _resolveLinkedPromotionBySubgraphInput(
subgraphInput: SubgraphInput
): PromotedWidgetSource | undefined {
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find(
(entry) => entry.link === linkId
)
if (!targetInput) continue
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
if (inputNode.isSubgraphNode()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.name
}
}
}
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
const cached = this._linkedEntriesCache
if (cache && cached?.version === this._cacheVersion) return cached.entries
const linkedEntries: LinkedPromotionEntry[] = []
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
if (boundWidget) {
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
const hasBoundSourceWidget =
boundNode?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) === true
if (hasBoundSourceWidget) {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
continue
}
}
const resolved =
this._resolveLinkedPromotionBySubgraphInput(subgraphInput)
if (!resolved) continue
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
slotName: subgraphInput.name,
...resolved
})
}
const seenEntryKeys = new Set<string>()
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName
)
if (seenEntryKeys.has(entryKey)) return false
seenEntryKeys.add(entryKey)
return true
})
if (cache)
this._linkedEntriesCache = {
version: this._cacheVersion,
entries: deduplicatedEntries
}
return deduplicatedEntries
}
private _getPromotedViews(): PromotedWidgetView[] {
const cachedViews = this._promotedViewsCache
if (cachedViews?.version === this._cacheVersion) return cachedViews.views
const linkedEntries = this._getLinkedPromotionEntries()
const reconcileEntries: Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey: string
slotName: string
}> = []
const displayNameByViewKey = new Map<string, string>()
for (const entry of linkedEntries) {
const viewKey = this._makePromotionViewKey(
entry.inputKey,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName
)
reconcileEntries.push({
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
slotName: entry.slotName,
viewKey
})
displayNameByViewKey.set(viewKey, entry.inputName)
}
const views = this._promotedViewManager.reconcile(
reconcileEntries,
(entry) =>
createPromotedWidgetView(
this,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.slotName
)
)
this._promotedViewsCache = {
version: this._cacheVersion,
views
}
return views
}
invalidatePromotedViews(): void {
this._cacheVersion++
}
private _makePromotionViewKey(
inputKey: string,
sourceNodeId: string,
sourceWidgetName: string,
inputName = ''
): string {
return JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
/**
* Retained as a no-op for extension compatibility: promoted host widgets are
* now store-backed and addressed by widgetId, so there is no view cache to
* invalidate.
*/
invalidatePromotedViews(): void {}
private _eventAbortController = new AbortController()
@@ -270,7 +95,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.graph = graph
Object.defineProperty(this, 'widgets', {
get: () => this._getPromotedViews(),
get: () => [],
set: () => {
if (import.meta.env.DEV)
console.warn(
@@ -307,13 +132,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const widget = inputNode.getWidgetFromSlot(input)
if (widget)
this._setWidget(
subgraphInput,
existingInput,
widget,
input.widget,
inputNode
)
this._setWidget(subgraphInput, existingInput, widget, input.widget)
return
}
const input = this.addInput(name, type, {
@@ -369,8 +188,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// identifier used by onGraphConfigured (widgetInputs.ts) to match
// inputs to widgets. Changing it to the display label would cause
// collisions when two promoted inputs share the same label.
// Display is handled via input.label and _widget.label.
if (input._widget) input._widget.label = newName
if (input.widgetId) {
const state = useWidgetValueStore().getWidget(input.widgetId)
if (state) state.label = newName
}
this.invalidatePromotedViews()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
@@ -437,33 +259,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-connected',
(e) => {
this.invalidatePromotedViews()
input.shape = this.getSlotShape(subgraphInput, e.detail.input)
if (!e.detail.widget || !e.detail.node) return
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
const hasStaleBoundWidget =
boundWidget &&
this.subgraph
.getNodeById(boundWidget.sourceNodeId)
?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) !== true
const shouldSetWidgetFromEvent = !input._widget || hasStaleBoundWidget
if (shouldSetWidgetFromEvent)
this._setWidget(
subgraphInput,
input,
e.detail.widget,
e.detail.input.widget,
e.detail.node
)
this.invalidatePromotedViews()
this._setWidget(
subgraphInput,
input,
e.detail.widget,
e.detail.input.widget
)
},
{ signal }
)
@@ -482,9 +286,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
if (input._widget) this.ensureWidgetRemoved(input._widget)
if (input.widgetId) useWidgetValueStore().deleteWidget(input.widgetId)
delete input.pos
delete input.widget
delete input.widgetId
input._widget = undefined
this.invalidatePromotedViews()
},
@@ -609,12 +415,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
let valueIndex = 0
for (const input of this.inputs) {
const view = input._widget
if (!view || !isPromotedWidgetView(view)) continue
if (!input.widgetId) continue
const value =
quarantineValuesByInputName.get(input.name) ??
widgetValues?.[valueIndex]
if (value !== undefined) view.hydrateHostValue(value)
if (value !== undefined) {
useWidgetValueStore().setValue(input.widgetId, value)
}
valueIndex += 1
}
}
@@ -639,7 +446,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this._rebindInputSubgraphSlots()
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
this._promotedViewManager.clear()
this.invalidatePromotedViews()
this._hydratePreviewExposures()
@@ -683,12 +489,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
rebuildInputWidgetBindings(): void {
this._promotedViewManager.clear()
this.invalidatePromotedViews()
for (const input of this.inputs) {
delete input.widget
delete input.pos
delete input.widgetId
input._widget = undefined
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
@@ -725,72 +531,71 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
if (widget) {
this._setWidget(subgraphInput, input, widget, targetInput.widget)
break
}
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
// Nested promotion: the source is itself a promoted subgraph input with
// no concrete widget. Resolve the deepest interior widget so this input
// still receives widget state and a widgetId.
const nested = this._resolveNestedPromotedSource(inputNode, targetInput)
if (nested) {
this._setWidget(subgraphInput, input, nested.widget, targetInput.widget)
break
}
}
}
private _resolveNestedPromotedSource(
inputNode: LGraphNode,
targetInput: INodeInputSlot
): { node: LGraphNode; widget: IBaseWidget } | undefined {
if (!inputNode.isSubgraphNode() || !targetInput.widgetId) return undefined
const target = resolveSubgraphInputTarget(inputNode, targetInput.name)
if (!target) return undefined
const resolved = resolveConcretePromotedWidget(
inputNode,
target.nodeId,
target.widgetName
)
return resolved.status === 'resolved' ? resolved.resolved : undefined
}
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,
interiorWidget: Readonly<IBaseWidget>,
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
inputWidget: IWidgetLocator | undefined
) {
this.invalidatePromotedViews()
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
input._widget = undefined
const previousView = input._widget
if (
previousView &&
isPromotedWidgetView(previousView) &&
(previousView.sourceNodeId !== nodeId ||
previousView.sourceWidgetName !== widgetName)
) {
this._removePromotedView(previousView)
}
const view = this._promotedViewManager.getOrCreate(
nodeId,
widgetName,
() =>
createPromotedWidgetView(
this,
nodeId,
widgetName,
input.label ?? subgraphInput.name,
subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
input.label ?? input.name
)
)
// NOTE: This code creates linked chains of prototypes for passing across
// multiple levels of subgraphs. As part of this, it intentionally avoids
// creating new objects. Have care when making changes.
input.widget ??= { name: subgraphInput.name }
input.widget.name = subgraphInput.name
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
input._widget = view
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
input.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled,
isDOMWidget:
'isDOMWidget' in interiorWidget &&
typeof interiorWidget.isDOMWidget === 'boolean'
? interiorWidget.isDOMWidget
: undefined
})
this.subgraph.events.dispatch('widget-promoted', {
widget: view,
widget: interiorWidget,
subgraphNode: this
})
}
@@ -816,10 +621,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override getSlotFromWidget(
widget: IBaseWidget | undefined
): INodeInputSlot | undefined {
if (!widget || !isPromotedWidgetView(widget))
return super.getSlotFromWidget(widget)
return this.inputs.find((input) => input._widget === widget)
if (widget?.widgetId) {
const promotedInput = this.inputs.find(
(input) => input.widgetId === widget.widgetId
)
if (promotedInput) return promotedInput
}
return super.getSlotFromWidget(widget)
}
override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
@@ -928,83 +736,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return nodes
}
private _clearDomOverrideForView(view: PromotedWidgetView): void {
const resolved = resolveConcretePromotedWidget(
this,
view.sourceNodeId,
view.sourceWidgetName
)
if (resolved.status !== 'resolved') return
const interiorWidget = resolved.resolved.widget
if (
interiorWidget &&
'id' in interiorWidget &&
('element' in interiorWidget || 'component' in interiorWidget)
) {
useDomWidgetStore().clearPositionOverride(String(interiorWidget.id))
}
}
private _removePromotedView(view: PromotedWidgetView): void {
this.invalidatePromotedViews()
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
for (const input of this.inputs) {
if (input._widget !== view || !input._subgraphSlot) continue
const inputName = input.label ?? input.name
this._promotedViewManager.removeByViewKey(
view.sourceNodeId,
view.sourceWidgetName,
this._makePromotionViewKey(
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,
inputName
)
)
}
}
override removeWidget(widget: IBaseWidget): void {
this.ensureWidgetRemoved(widget)
}
override ensureWidgetRemoved(widget: IBaseWidget): void {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
if (input._widget === widget) {
input._widget = undefined
input.widget = undefined
}
}
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
this.invalidatePromotedViews()
}
override onRemoved(): void {
this._eventAbortController.abort()
this.invalidatePromotedViews()
for (const widget of this.widgets) {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
}
this.subgraph.events.dispatch('widget-demoted', {
widget,
subgraphNode: this
})
}
this._promotedViewManager.clear()
for (const input of this.inputs) {
if (
@@ -1074,24 +818,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
serialized.properties = serializedProperties
if (
import.meta.env?.DEV &&
this.widgets.some((w) => !isPromotedWidgetView(w))
) {
console.warn(
`SubgraphNode ${this.id}: serialize() drops non-promoted host widgets ` +
`(${this.widgets
.filter((w) => !isPromotedWidgetView(w))
.map((w) => w.name)
.join(', ')}); ` +
'expected only PromotedWidgetView instances.'
)
}
const widgetValues = this.inputs.flatMap((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return []
const value = readWidgetValue(widget.entityId)
if (!input.widgetId) return []
const value = useWidgetValueStore().getWidget(input.widgetId)?.value
return [isWidgetValue(value) ? value : undefined]
})

View File

@@ -8,20 +8,18 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import {
appendQuarantine,
flushProxyWidgetMigration,
makeQuarantineEntry
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { reorderSubgraphInputsByName } from '@/core/graph/subgraph/promotionUtils'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
@@ -77,15 +75,46 @@ function setupPromotedWidget(
return createTestSubgraphNode(subgraph)
}
function expectPromotedWidgetView(
widget: unknown
): asserts widget is PromotedWidgetView {
expect(widget).toMatchObject({
sourceNodeId: expect.any(String),
sourceWidgetName: expect.any(String)
function promotedInputs(node: {
inputs: Array<{ widgetId?: WidgetId; name: string }>
}) {
return node.inputs.filter(
(input): input is { widgetId: WidgetId; name: string } =>
Boolean(input.widgetId)
)
}
function promotedWidgetStates(node: {
inputs: Array<{ widgetId?: WidgetId; name: string }>
}) {
return promotedInputs(node).map((input) => {
const state = useWidgetValueStore().getWidget(input.widgetId)
if (!state) throw new Error(`Missing widget state ${input.widgetId}`)
return state
})
}
function promotedWidgetStateByName(
node: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
name: string
) {
const input = node.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
const state = useWidgetValueStore().getWidget(input.widgetId)
if (!state) throw new Error(`Missing widget state ${input.widgetId}`)
return state
}
function writePromotedWidgetValue(
node: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
index: number,
value: unknown
) {
const input = promotedInputs(node)[index]
if (!input) throw new Error(`Missing promoted input ${index}`)
useWidgetValueStore().setValue(input.widgetId, value)
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
@@ -101,11 +130,11 @@ describe('SubgraphWidgetPromotion', () => {
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
// The widget should be promoted to the subgraph node
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('value') // Uses subgraph input name
expect(subgraphNode.widgets[0].type).toBe('number')
expect(subgraphNode.widgets[0].value).toBe(42)
expect(subgraphNode.widgets).toHaveLength(0)
expect(promotedWidgetStateByName(subgraphNode, 'value')).toMatchObject({
type: 'number',
value: 42
})
})
it('should promote all widget types', () => {
@@ -147,13 +176,10 @@ describe('SubgraphWidgetPromotion', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
// All widgets should be promoted
expect(subgraphNode.widgets).toHaveLength(3)
// Check specific widget values
expect(subgraphNode.widgets[0].value).toBe(100)
expect(subgraphNode.widgets[1].value).toBe('test')
expect(subgraphNode.widgets[2].value).toBe(true)
expect(subgraphNode.widgets).toHaveLength(0)
expect(
promotedWidgetStates(subgraphNode).map((state) => state.value)
).toEqual([100, 'test', true])
})
it('should fire widget-promoted event when widget is promoted', () => {
@@ -229,13 +255,11 @@ describe('SubgraphWidgetPromotion', () => {
// Create SubgraphNode
const subgraphNode = createTestSubgraphNode(subgraph)
// Both widgets should be promoted
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].name).toBe('input1')
expect(subgraphNode.widgets[0].value).toBe(10)
expect(subgraphNode.widgets[1].name).toBe('input2')
expect(subgraphNode.widgets[1].value).toBe('hello')
expect(subgraphNode.widgets).toHaveLength(0)
expect(promotedWidgetStateByName(subgraphNode, 'input1').value).toBe(10)
expect(promotedWidgetStateByName(subgraphNode, 'input2').value).toBe(
'hello'
)
})
it('should fire widget-demoted events when node is removed', () => {
@@ -246,7 +270,8 @@ describe('SubgraphWidgetPromotion', () => {
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets).toHaveLength(0)
expect(promotedInputs(subgraphNode)).toHaveLength(1)
const eventCapture = createEventCapture(subgraph.events, [
'widget-demoted'
@@ -255,9 +280,9 @@ describe('SubgraphWidgetPromotion', () => {
// Remove the subgraph node
subgraphNode.onRemoved()
// Should fire demoted events for all widgets
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
expect(demotedEvents).toHaveLength(1)
expect(demotedEvents).toHaveLength(0)
expect(promotedInputs(subgraphNode)).toHaveLength(1)
eventCapture.cleanup()
})
@@ -284,13 +309,13 @@ describe('SubgraphWidgetPromotion', () => {
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets).toHaveLength(0)
expect(promotedInputs(subgraphNode)).toHaveLength(1)
// Disconnect the link
subgraph.inputNode.slots[0].disconnect()
// Widget should be removed (through event listeners)
expect(subgraphNode.widgets).toHaveLength(0)
expect(promotedInputs(subgraphNode)).toHaveLength(0)
})
})
@@ -320,10 +345,12 @@ describe('SubgraphWidgetPromotion', () => {
hostNode.configure(serializedHostNode)
expect(hostNode.widgets).toHaveLength(1)
expect(hostNode.widgets[0].name).toBe('batch_size')
expect(hostNode.widgets[0].value).toBe(1)
expect(hostNode.widgets[0].options.step).toBe(10)
expect(hostNode.widgets).toHaveLength(0)
expect(promotedWidgetStateByName(hostNode, 'batch_size')).toMatchObject({
name: 'batch_size',
value: 1,
options: expect.objectContaining({ step: 10 })
})
})
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
@@ -364,18 +391,17 @@ describe('SubgraphWidgetPromotion', () => {
outerNode.configure(outerNode.serialize())
// Check widgets getter — stale entries should not produce views
const widgetSourceIds = outerNode.widgets
.filter(isPromotedWidgetView)
.filter((w) => !w.name.startsWith('$$'))
.map((w) => w.sourceNodeId)
expect(widgetSourceIds).not.toContain('999')
expect(widgetSourceIds).not.toContain('998')
expect(widgetSourceIds).toContain(keptSamplerNodeId)
expect(outerNode.widgets).toHaveLength(0)
expect(promotedWidgetStateByName(outerNode, 'model').value).toBe(42)
expect(outerNode.properties.proxyWidgets).toEqual([
['999', 'text'],
['998', 'text'],
[keptSamplerNodeId, 'widget']
])
expect(keptSamplerNodeId).toBe(String(samplerNode.id))
})
it('resolves legacy prefixed proxyWidgets via the immediate child PromotedWidgetView identity', () => {
it('resolves legacy prefixed proxyWidgets via the immediate child promoted-widget identity', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
@@ -417,12 +443,7 @@ 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]?.sourceNodeId).toBe(String(nestedNode.id))
expect(hostNode.widgets).toHaveLength(0)
expect(hostNode.properties.proxyWidgets).toBeUndefined()
expect(hostNode.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
@@ -446,12 +467,8 @@ describe('SubgraphWidgetPromotion', () => {
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const promotedNames = cloneNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
.map((widget) => widget.sourceWidgetName)
expect(promotedNames).toContain('text')
expect(cloneNode.widgets).toHaveLength(0)
expect(promotedWidgetStateByName(cloneNode, 'text').value).toBe('')
})
})
@@ -462,6 +479,9 @@ describe('SubgraphWidgetPromotion', () => {
})
const originalTooltip = 'This is a test tooltip'
const eventCapture = createEventCapture(subgraph.events, [
'widget-promoted'
])
const { node } = createNodeWithWidget(
'Test Node',
'number',
@@ -471,9 +491,11 @@ describe('SubgraphWidgetPromotion', () => {
)
const subgraphNode = setupPromotedWidget(subgraph, node)
// The promoted widget should preserve the original tooltip
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].tooltip).toBe(originalTooltip)
expect(subgraphNode.widgets).toHaveLength(0)
expect(
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
).toBe(originalTooltip)
eventCapture.cleanup()
})
it('should handle widgets with no tooltip', () => {
@@ -481,12 +503,17 @@ describe('SubgraphWidgetPromotion', () => {
inputs: [{ name: 'value', type: 'number' }]
})
const eventCapture = createEventCapture(subgraph.events, [
'widget-promoted'
])
const { node } = createNodeWithWidget('Test Node', 'number', 42, 'number')
const subgraphNode = setupPromotedWidget(subgraph, node)
// The promoted widget should have undefined tooltip
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].tooltip).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
expect(
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
).toBeUndefined()
eventCapture.cleanup()
})
it('should preserve tooltips for multiple promoted widgets', () => {
@@ -539,13 +566,18 @@ describe('SubgraphWidgetPromotion', () => {
multiWidgetNode
)
// Create SubgraphNode
const eventCapture = createEventCapture(subgraph.events, [
'widget-promoted'
])
const subgraphNode = createTestSubgraphNode(subgraph)
// Both widgets should preserve their tooltips
expect(subgraphNode.widgets).toHaveLength(2)
expect(subgraphNode.widgets[0].tooltip).toBe('Number widget tooltip')
expect(subgraphNode.widgets[1].tooltip).toBe('String widget tooltip')
expect(subgraphNode.widgets).toHaveLength(0)
expect(
eventCapture
.getEventsByType('widget-promoted')
.map((event) => event.detail.widget.tooltip)
).toEqual(['Number widget tooltip', 'String widget tooltip'])
eventCapture.cleanup()
})
it('should preserve original tooltip after promotion', () => {
@@ -554,6 +586,9 @@ describe('SubgraphWidgetPromotion', () => {
})
const originalTooltip = 'Original tooltip'
const eventCapture = createEventCapture(subgraph.events, [
'widget-promoted'
])
const { node } = createNodeWithWidget(
'Test Node',
'number',
@@ -562,16 +597,14 @@ describe('SubgraphWidgetPromotion', () => {
originalTooltip
)
const subgraphNode = setupPromotedWidget(subgraph, node)
const state = promotedWidgetStateByName(subgraphNode, 'value')
const promotedWidget = subgraphNode.widgets[0]
// The promoted widget should preserve the original tooltip
expect(promotedWidget.tooltip).toBe(originalTooltip)
// The promoted widget should still function normally
expect(promotedWidget.name).toBe('value') // Uses subgraph input name
expect(promotedWidget.type).toBe('number')
expect(promotedWidget.value).toBe(42)
expect(subgraphNode.widgets).toHaveLength(0)
expect(
eventCapture.getEventsByType('widget-promoted')[0].detail.widget.tooltip
).toBe(originalTooltip)
expect(state).toMatchObject({ name: 'value', type: 'number', value: 42 })
eventCapture.cleanup()
})
})
@@ -586,15 +619,7 @@ describe('SubgraphWidgetPromotion', () => {
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
const hostWidget = hostNode.widgets[0]
expectPromotedWidgetView(hostWidget)
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
nodeId: hostNode.id,
name: hostWidget.name,
type: hostWidget.type,
value: 99,
options: {}
})
writePromotedWidgetValue(hostNode, 0, 99)
hostNode.serialize()
expect(interiorWidget.value).toBe(42)
@@ -684,28 +709,11 @@ describe('SubgraphWidgetPromotion', () => {
return built
}
function vueEdit(
host: ReturnType<typeof createTestSubgraphNode>,
index: number,
value: EditValue
) {
const widgets = computeProcessedWidgets({
nodeData: extractVueNodeData(host),
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
})
widgets[index].updateHandler(value)
}
function applyEdit(
host: ReturnType<typeof createTestSubgraphNode>,
edit: EditSpec
) {
if (edit.via === 'viewKey') host.widgets[edit.index].value = edit.value
else vueEdit(host, edit.index, edit.value)
writePromotedWidgetValue(host, edit.index, edit.value)
}
function applyReorder(
@@ -808,7 +816,9 @@ describe('SubgraphWidgetPromotion', () => {
applyReorder(host, c.reorder)
if (c.expectedNames) {
expect(host.widgets.map((w) => w.name)).toEqual(c.expectedNames)
expect(promotedInputs(host).map((input) => input.name)).toEqual(
c.expectedNames
)
}
if (c.expectedWidgetsValues !== undefined) {
expect(host.serialize().widgets_values).toEqual(
@@ -833,13 +843,9 @@ describe('SubgraphWidgetPromotion', () => {
controlMarker: boolean
seedHostValue: number
mutateSourceSeedAfterReorder?: number
callAfterQueued?: boolean
expect: {
promptSeed?: number
sourceSeed?: number
processedSeedValue?: number
hostSeedValue?: number
storeSeedValue?: number
}
}
@@ -860,25 +866,6 @@ describe('SubgraphWidgetPromotion', () => {
controlMarker: false,
seedHostValue: 123456,
expect: { sourceSeed: 0 }
},
{
name: 'Vue + increment + afterQueued: processed widgets reflect increment',
editVia: 'vue',
controlMode: 'increment',
controlMarker: true,
seedHostValue: 123456,
callAfterQueued: true,
expect: { processedSeedValue: 123457 }
},
{
name: 'ViewKey + increment + afterQueued: host seed increments without source value',
editVia: 'viewKey',
controlMode: 'increment',
controlMarker: true,
seedHostValue: 2,
mutateSourceSeedAfterReorder: 8,
callAfterQueued: true,
expect: { hostSeedValue: 3, storeSeedValue: 3 }
}
]
@@ -897,26 +884,18 @@ describe('SubgraphWidgetPromotion', () => {
host.graph?.add(host)
}
if (c.editVia === 'viewKey') {
host.widgets[0].value = 'positive prompt'
host.widgets[1].value = 'negative prompt'
host.widgets[2].value = c.seedHostValue
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
} else {
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
vueEdit(host, 2, c.seedHostValue)
}
writePromotedWidgetValue(host, 0, 'positive prompt')
writePromotedWidgetValue(host, 1, 'negative prompt')
writePromotedWidgetValue(host, 2, c.seedHostValue)
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
if (c.mutateSourceSeedAfterReorder !== undefined) {
seed.widget.value = c.mutateSourceSeedAfterReorder
}
if (c.callAfterQueued) host.widgets[1].afterQueued?.()
if (c.expect.promptSeed !== undefined) {
const { output } = await graphToPrompt(host.rootGraph)
@@ -927,53 +906,6 @@ describe('SubgraphWidgetPromotion', () => {
if (c.expect.sourceSeed !== undefined) {
expect(seed.widget.value).toBe(c.expect.sourceSeed)
}
if (c.expect.processedSeedValue !== undefined) {
const updated = computeProcessedWidgets({
nodeData: extractVueNodeData(host),
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
})
expect(updated[1].value).toBe(c.expect.processedSeedValue)
}
if (c.expect.hostSeedValue !== undefined) {
expect(host.widgets[1].value).toBe(c.expect.hostSeedValue)
}
if (c.expect.storeSeedValue !== undefined) {
expect(
useWidgetValueStore()
.getNodeWidgets(host.rootGraph.id, host.id)
.find((entry) => entry.name === 'seed')?.value
).toBe(c.expect.storeSeedValue)
}
})
it('afterQueued does not run value-control when the host input is externally linked', () => {
const subgraph = createTestSubgraph()
const sources = buildSources(
subgraph,
TEXT_TEXT_SEED.map((s) =>
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
)
)
const seed = sources[2]
const host = createTestSubgraphNode(subgraph)
seed.widget.linkedWidgets = [
makeControlWidget('increment', true) as never
]
host.widgets[2].value = 2
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
const seedSlot = host.getSlotFromWidget(host.widgets[1])
expect(seedSlot).toBeDefined()
seedSlot!.link = -1
host.widgets[1].afterQueued?.()
expect(host.widgets[1].value).toBe(2)
})
it('serializes promoted values from each host independently', () => {
@@ -993,8 +925,8 @@ describe('SubgraphWidgetPromotion', () => {
subgraph.rootGraph.add(firstHost)
subgraph.rootGraph.add(secondHost)
firstHost.widgets[0].value = 111
secondHost.widgets[0].value = 222
writePromotedWidgetValue(firstHost, 0, 111)
writePromotedWidgetValue(secondHost, 0, 222)
expect(firstHost.serialize().widgets_values).toEqual([111])
expect(secondHost.serialize().widgets_values).toEqual([222])
@@ -1006,16 +938,17 @@ describe('SubgraphWidgetPromotion', () => {
const host = createTestSubgraphNode(subgraph)
const widgetStore = useWidgetValueStore()
for (const { node, widget } of sources) {
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: node.id,
name: widget.name,
type: widget.type,
value: `${node.title} value`,
options: {}
})
widgetStore.registerWidget(
widgetId(host.rootGraph.id, node.id, widget.name),
{
type: widget.type,
value: `${node.title} value`,
options: {}
}
)
}
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toBeUndefined()
expect(host.serialize().widgets_values).toEqual(['', ''])
})
it('does not acquire a host overlay when a source fallback is saved and reloaded', () => {
@@ -1032,15 +965,16 @@ describe('SubgraphWidgetPromotion', () => {
const host = createTestSubgraphNode(subgraph, { id: 101 })
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: interiorNode.id,
name: interiorWidget.name,
type: interiorWidget.type,
value: 'source fallback',
options: {}
})
widgetStore.registerWidget(
widgetId(host.rootGraph.id, interiorNode.id, interiorWidget.name),
{
type: interiorWidget.type,
value: 'source fallback',
options: {}
}
)
const serialized = host.serialize()
expect(serialized.widgets_values).toBeUndefined()
expect(serialized.widgets_values).toEqual([''])
widgetStore.clearGraph(host.rootGraph.id)
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
@@ -1048,8 +982,8 @@ describe('SubgraphWidgetPromotion', () => {
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
).toEqual([])
expect(reloaded.serialize().widgets_values).toBeUndefined()
).toHaveLength(1)
expect(reloaded.serialize().widgets_values).toEqual([''])
})
it('does not hydrate missing widgets_values entries as explicit host overlays', () => {
@@ -1057,33 +991,31 @@ describe('SubgraphWidgetPromotion', () => {
buildSources(subgraph, TEXT_PAIR)
const host = createTestSubgraphNode(subgraph, { id: 101 })
host.widgets[1].value = 'second host value'
writePromotedWidgetValue(host, 1, 'second host value')
const serialized = host.serialize()
expect(serialized.widgets_values).toEqual([
undefined,
'second host value'
])
expect(serialized.widgets_values).toEqual(['', 'second host value'])
const widgetStore = useWidgetValueStore()
widgetStore.clearGraph(host.rootGraph.id)
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
reloaded.configure(serialized)
const [first, second] = reloaded.widgets
expectPromotedWidgetView(first)
expectPromotedWidgetView(second)
expect(reloaded.widgets).toHaveLength(0)
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
).toBeUndefined()
widgetStore.getWidget(
widgetId(reloaded.rootGraph.id, reloaded.id, 'first')
)?.value
).toBe('')
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
?.value
widgetStore.getWidget(
widgetId(reloaded.rootGraph.id, reloaded.id, 'second')
)?.value
).toBe('second host value')
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
).toHaveLength(1)
).toHaveLength(2)
expect(reloaded.serialize().widgets_values).toEqual([
undefined,
'',
'second host value'
])
})
@@ -1369,7 +1301,12 @@ describe('SubgraphWidgetPromotion', () => {
reloaded.configure(serialized)
const byName = new Map(
reloaded.inputs.map((input) => [input.name, input._widget?.value])
reloaded.inputs.map((input) => [
input.name,
input.widgetId
? useWidgetValueStore().getWidget(input.widgetId)?.value
: undefined
])
)
expect(byName.get('unet_name')).toBe('z_image_turbo_bf16.safetensors')
expect(byName.get('clip_name')).toBe('qwen_3_4b.safetensors')

View File

@@ -0,0 +1,2 @@
export type { WidgetId } from '@/types/widgetId'
export { widgetId } from '@/types/widgetId'

View File

@@ -1,6 +1,6 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import type {
CanvasColour,
@@ -399,7 +399,7 @@ export interface IBaseWidget<
linkedWidgets?: IBaseWidget[]
readonly entityId?: WidgetEntityId
readonly widgetId?: WidgetId
name: string
options: TOptions

View File

@@ -7,6 +7,7 @@ import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
function createTestWidget(
node: LGraphNode,
@@ -95,7 +96,7 @@ describe('BaseWidget store integration', () => {
widget.disabled = true
widget.advanced = true
const state = store.getWidget(graph.id, 1, 'writeWidget')
const state = store.getWidget(widgetId(graph.id, 1, 'writeWidget'))
expect(state?.label).toBe('Updated Label')
expect(state?.disabled).toBe(true)
@@ -108,9 +109,11 @@ describe('BaseWidget store integration', () => {
widget.setNodeId(1)
widget.value = 99
expect(store.getWidget(graph.id, 1, 'valueWidget')?.value).toBe(99)
expect(store.getWidget(widgetId(graph.id, 1, 'valueWidget'))?.value).toBe(
99
)
const state = store.getWidget(graph.id, 1, 'valueWidget')!
const state = store.getWidget(widgetId(graph.id, 1, 'valueWidget'))!
state.value = 55
expect(widget.value).toBe(55)
})
@@ -128,9 +131,9 @@ describe('BaseWidget store integration', () => {
})
widget.setNodeId(1)
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
const state = store.getWidget(widgetId(graph.id, 1, 'autoRegWidget'))
expect(state).toBeDefined()
expect(state?.nodeId).toBe(1)
expect(state?.nodeId).toBe('1')
expect(state?.name).toBe('autoRegWidget')
expect(state?.type).toBe('number')
expect(state?.value).toBe(100)
@@ -146,7 +149,7 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'defaultsWidget' })
widget.setNodeId(1)
const state = store.getWidget(graph.id, 1, 'defaultsWidget')
const state = store.getWidget(widgetId(graph.id, 1, 'defaultsWidget'))
expect(state).toBeDefined()
expect(state?.disabled).toBe(false)
expect(state?.label).toBeUndefined()
@@ -159,7 +162,9 @@ describe('BaseWidget store integration', () => {
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
widget.setNodeId(1)
expect(store.getWidget(graph.id, 1, 'valuesWidget')?.value).toBe(77)
expect(
store.getWidget(widgetId(graph.id, 1, 'valuesWidget'))?.value
).toBe(77)
})
})
@@ -177,20 +182,26 @@ describe('BaseWidget store integration', () => {
get() {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return defaultValue
const state = store.getWidget(graphId, node.id, 'system_prompt')
const state = store.getWidget(
widgetId(graphId, node.id, 'system_prompt')
)
return (state?.value as string) ?? defaultValue
},
set(v: string) {
const graphId = widget.node.graph?.rootGraph.id
if (!graphId) return
const state = store.getWidget(graphId, node.id, 'system_prompt')
const state = store.getWidget(
widgetId(graphId, node.id, 'system_prompt')
)
if (state) state.value = v
}
})
widget.setNodeId(node.id)
const state = store.getWidget(graph.id, node.id, 'system_prompt')
const state = store.getWidget(
widgetId(graph.id, node.id, 'system_prompt')
)
expect(state?.value).toBe(defaultValue)
})
})
@@ -211,7 +222,7 @@ describe('BaseWidget store integration', () => {
widget.disabled = undefined
const state = store.getWidget(graph.id, 1, 'testWidget')
const state = store.getWidget(widgetId(graph.id, 1, 'testWidget'))
expect(state?.disabled).toBe(false)
})
})

View File

@@ -10,7 +10,7 @@ import type {
LGraphNode,
Size
} from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { litegraph } from '@/lib/litegraph/src/litegraphInstance'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type {
IBaseWidget,
@@ -19,8 +19,8 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/lib/litegraph/src/types/widgetId'
import { widgetId } from '@/lib/litegraph/src/types/widgetId'
export interface DrawWidgetOptions {
/** The width of the node where this widget will be displayed. */
@@ -132,11 +132,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state.value = value
}
get entityId(): WidgetEntityId | undefined {
get widgetId(): WidgetId | undefined {
const graphId = this.node.graph?.rootGraph.id
const nodeId = this._state.nodeId
if (!graphId || nodeId === undefined) return undefined
return widgetEntityId(graphId, nodeId, this.name)
return widgetId(graphId, nodeId, this.name)
}
/**
@@ -147,14 +147,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._state = useWidgetValueStore().registerWidget(graphId, {
...this._state,
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
value: this.value,
nodeId
})
this._state = useWidgetValueStore().registerWidget(
widgetId(graphId, nodeId, this.name),
{
...this._state,
value: this.value
}
)
}
constructor(widget: TWidget & { node: LGraphNode })
@@ -214,8 +213,8 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
getOutlineColor() {
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR
? litegraph().WIDGET_ADVANCED_OUTLINE_COLOR
: litegraph().WIDGET_OUTLINE_COLOR
}
get outline_color() {
@@ -223,23 +222,23 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
get background_color() {
return LiteGraph.WIDGET_BGCOLOR
return litegraph().WIDGET_BGCOLOR
}
get height() {
return LiteGraph.NODE_WIDGET_HEIGHT
return litegraph().NODE_WIDGET_HEIGHT
}
get text_color() {
return LiteGraph.WIDGET_TEXT_COLOR
return litegraph().WIDGET_TEXT_COLOR
}
get secondary_text_color() {
return LiteGraph.WIDGET_SECONDARY_TEXT_COLOR
return litegraph().WIDGET_SECONDARY_TEXT_COLOR
}
get disabledTextColor() {
return LiteGraph.WIDGET_DISABLED_TEXT_COLOR
return litegraph().WIDGET_DISABLED_TEXT_COLOR
}
get displayName() {
@@ -361,7 +360,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
if (requiredWidth <= totalWidth) {
// Draw label & value normally
drawTextInArea({ ctx, text: displayName, area, align: 'left' })
} else if (LiteGraph.truncateWidgetTextEvenly) {
} else if (litegraph().truncateWidgetTextEvenly) {
// Label + value will not fit - scale evenly to fit
const scale = (totalWidth - gap) / (requiredWidth - gap)
area.width = labelWidth * scale
@@ -371,7 +370,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
// Move the area to the right to render the value
area.right = x + totalWidth
area.setWidthRightAnchored(valueWidth * scale)
} else if (LiteGraph.truncateWidgetValuesFirst) {
} else if (litegraph().truncateWidgetValuesFirst) {
// Label + value will not fit - use legacy scaling of value first
const cappedLabelWidth = Math.min(labelWidth, totalWidth)

View File

@@ -4,11 +4,11 @@ import { computed } from 'vue'
import type { LinearInput } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import { cn } from '@comfyorg/tailwind-utils'
const { entityId, name } = defineProps<{
entityId?: WidgetEntityId
const { widgetId, name } = defineProps<{
widgetId?: WidgetId
enable: boolean
name: string
}>()
@@ -17,12 +17,12 @@ const appModeStore = useAppModeStore()
const isPromoted = computed(() => appModeStore.selectedInputs.some(matchesThis))
function matchesThis([storedId]: LinearInput) {
return entityId !== undefined && storedId === entityId
return widgetId !== undefined && storedId === widgetId
}
function togglePromotion() {
if (!entityId) return
if (!widgetId) return
if (isPromoted.value) remove(appModeStore.selectedInputs, matchesThis)
else appModeStore.selectedInputs.push([entityId, name])
else appModeStore.selectedInputs.push([widgetId, name])
}
</script>
<template>

View File

@@ -12,7 +12,7 @@ import type {
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetEntityId } from '@/world/entityIds'
import { widgetId } from '@/world/entityIds'
const GRAPH_ID = 'graph-test'
@@ -129,12 +129,12 @@ describe('NodeWidgets', () => {
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
const duplicateEntityId = widgetEntityId(
const duplicateEntityId = widgetId(
GRAPH_ID,
'5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
'string_a'
)
const distinctEntityId = widgetEntityId(
const distinctEntityId = widgetId(
GRAPH_ID,
'5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
'string_a'
@@ -143,22 +143,19 @@ describe('NodeWidgets', () => {
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
entityId: duplicateEntityId,
slotName: 'string_a'
widgetId: duplicateEntityId
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
entityId: duplicateEntityId,
slotName: 'string_a'
widgetId: duplicateEntityId
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
entityId: distinctEntityId,
slotName: 'string_a'
widgetId: distinctEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
duplicateA,
@@ -172,7 +169,7 @@ describe('NodeWidgets', () => {
})
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
const sharedEntityId = widgetEntityId(
const sharedEntityId = widgetId(
GRAPH_ID,
'5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
'string_a'
@@ -181,16 +178,14 @@ describe('NodeWidgets', () => {
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
entityId: sharedEntityId,
slotName: 'string_a',
widgetId: sharedEntityId,
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
entityId: sharedEntityId,
slotName: 'string_a',
widgetId: sharedEntityId,
options: { hidden: false }
})
const nodeData = createMockNodeData('SubgraphNode', [
@@ -204,7 +199,7 @@ describe('NodeWidgets', () => {
})
it('does not deduplicate entries that share names but have different widget types', () => {
const sharedEntityId = widgetEntityId(
const sharedEntityId = widgetId(
GRAPH_ID,
'5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
'string_a'
@@ -213,15 +208,13 @@ describe('NodeWidgets', () => {
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
entityId: sharedEntityId,
slotName: 'string_a'
widgetId: sharedEntityId
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
entityId: sharedEntityId,
slotName: 'string_a'
widgetId: sharedEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
textWidget,
@@ -237,14 +230,12 @@ describe('NodeWidgets', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
slotName: 'string_a',
type: 'text',
sourceExecutionId: '65:18'
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
slotName: 'string_a',
type: 'text',
sourceExecutionId: '65:19'
})
@@ -263,15 +254,13 @@ describe('NodeWidgets', () => {
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:1',
entityId: widgetEntityId(GRAPH_ID, 'outer-subgraph:1', 'text'),
slotName: 'text'
widgetId: widgetId(GRAPH_ID, 'outer-subgraph:1', 'text')
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:2',
entityId: widgetEntityId(GRAPH_ID, 'outer-subgraph:2', 'text'),
slotName: 'text'
widgetId: widgetId(GRAPH_ID, 'outer-subgraph:2', 'text')
})
const nodeData = createMockNodeData('SubgraphNode', [
@@ -294,37 +283,38 @@ describe('NodeWidgets', () => {
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget('graph-test', {
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
})
widgetValueStore.registerWidget(
widgetId('graph-test', 'test_node', 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
}
)
await nextTick()
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
})
it('forwards canonical entityId to AppInput for selection', () => {
const seedAEntityId = widgetEntityId(GRAPH_ID, 'test_node', 'seed_a')
const seedBEntityId = widgetEntityId(GRAPH_ID, 'test_node', 'seed_b')
it('forwards canonical widgetId to AppInput for selection', () => {
const seedAEntityId = widgetId(GRAPH_ID, 'test_node', 'seed_a')
const seedBEntityId = widgetId(GRAPH_ID, 'test_node', 'seed_b')
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: 'test_node',
name: 'seed_a',
type: 'text',
entityId: seedAEntityId
widgetId: seedAEntityId
}),
createMockWidget({
nodeId: 'test_node',
name: 'seed_b',
type: 'text',
entityId: seedBEntityId
widgetId: seedBEntityId
})
])
@@ -341,9 +331,9 @@ describe('NodeWidgets', () => {
stubs: {
InputSlot: true,
AppInput: {
props: ['entityId', 'name', 'enable'],
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="entityId"><slot /></div>'
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
},
mocks: {

View File

@@ -54,7 +54,7 @@
</div>
<!-- Widget Component -->
<AppInput
:entity-id="widget.entityId"
:widget-id="widget.widgetId"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>

View File

@@ -12,6 +12,7 @@ import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import { widgetId } from '@/types/widgetId'
function splitAroundFirstSpace(text: string): [string, string | undefined] {
const index = text.indexOf(' ')
@@ -46,7 +47,7 @@ export function trackNodePrice(node: TrackableNode) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(graphId, node.id, name)?.value
void widgetStore.getWidget(widgetId(graphId, node.id, name))?.value
}
}
// Access input connections for regular inputs
@@ -148,7 +149,8 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(graphId, nodeData.id, name)?.value
void widgetStore.getWidget(widgetId(graphId, nodeData.id, name))
?.value
}
}
// Access input connections for regular inputs

View File

@@ -13,7 +13,7 @@ import {
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetEntityId } from '@/world/entityIds'
import { widgetId } from '@/world/entityIds'
const GRAPH_ID = 'graph-test'
@@ -44,11 +44,15 @@ const createMockWidget = (
})
describe('getWidgetIdentity', () => {
it('keys dedupeIdentity by entityId and widget type', () => {
const entityId = widgetEntityId(GRAPH_ID, 'subgraph:19', 'text')
const widget = createMockWidget({ entityId, name: 'text', type: 'text' })
it('keys dedupeIdentity by widgetId and widget type', () => {
const id = widgetId(GRAPH_ID, 'subgraph:19', 'text')
const widget = createMockWidget({
widgetId: id,
name: 'text',
type: 'text'
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '1', 0)
expect(dedupeIdentity).toBe(`${entityId}:text`)
expect(dedupeIdentity).toBe(`${id}:text`)
expect(renderKey).toBe(dedupeIdentity)
})
@@ -58,7 +62,7 @@ describe('getWidgetIdentity', () => {
sourceExecutionId: undefined
})
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
expect(dedupeIdentity).toBe('node:5:test_widget:combo')
expect(renderKey).toBe(dedupeIdentity)
})
@@ -73,7 +77,7 @@ describe('getWidgetIdentity', () => {
3
)
expect(dedupeIdentity).toBeUndefined()
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
expect(renderKey).toBe('transient::test_widget:combo:3')
})
it('uses sourceExecutionId for identity when no nodeId', () => {
@@ -82,7 +86,7 @@ describe('getWidgetIdentity', () => {
sourceExecutionId: '65:18'
})
const { dedupeIdentity } = getWidgetIdentity(widget, '1', 0)
expect(dedupeIdentity).toBe('exec:65:18:test_widget:test_widget:combo')
expect(dedupeIdentity).toBe('exec:65:18:test_widget:combo')
})
})
@@ -187,10 +191,10 @@ describe('hasWidgetError', () => {
).toBe(true)
})
it('uses slotName for error matching when present', () => {
it('matches errors by the slot name (widget.name) for promoted widgets', () => {
const widget = createMockWidget({
name: 'internal_name',
slotName: 'display_slot'
name: 'display_slot',
sourceWidgetName: 'internal_name'
})
const nodeErrors = {
errors: [{ extra_info: { input_name: 'display_slot' } }]
@@ -205,6 +209,27 @@ describe('hasWidgetError', () => {
)
).toBe(true)
})
it('matches missing models by the interior source widget name', () => {
const widget = createMockWidget({
name: 'display_slot',
sourceExecutionId: '65:18',
sourceWidgetName: 'ckpt_name'
})
const spy = vi
.spyOn(missingModelStore, 'isWidgetMissingModel')
.mockReturnValue(true)
expect(
hasWidgetError(
widget,
'1',
undefined,
executionErrorStore,
missingModelStore
)
).toBe(true)
expect(spy).toHaveBeenCalledWith('65:18', 'ckpt_name')
})
})
const noopUi = {
@@ -218,13 +243,18 @@ describe('computeProcessedWidgets borderStyle', () => {
})
it('does not apply border styling to promoted widgets', () => {
const id = widgetId(GRAPH_ID, 'inner-subgraph:1', 'text')
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'a',
options: {},
label: 'Text'
})
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
entityId: widgetEntityId(GRAPH_ID, 'inner-subgraph:1', 'text'),
slotName: 'text',
promotedLabel: 'Text'
widgetId: id
})
const result = computeProcessedWidgets({
@@ -255,8 +285,7 @@ describe('computeProcessedWidgets borderStyle', () => {
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
entityId: widgetEntityId(GRAPH_ID, 'inner-subgraph:1', 'text'),
slotName: 'text'
widgetId: widgetId(GRAPH_ID, 'inner-subgraph:1', 'text')
})
const result = computeProcessedWidgets({
@@ -314,14 +343,61 @@ describe('computeProcessedWidgets borderStyle', () => {
)
})
it('reads widget identity, value, label, and options from widgetId state', () => {
const id = widgetId(GRAPH_ID, 'host', 'text')
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, 'host', 'text'), {
type: 'combo',
value: 'state value',
label: 'State Label',
options: { values: ['state value'] }
})
const widget = createMockWidget({
widgetId: id,
nodeId: 'host',
name: 'stale name',
type: 'combo',
options: { values: ['stale value'] }
})
const result = computeProcessedWidgets({
nodeData: {
id: '3',
type: 'SubgraphNode',
widgets: [widget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: GRAPH_ID,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(result[0]).toMatchObject({
widgetId: id,
renderKey: `${id}:combo`,
value: 'state value',
simplified: {
name: 'text',
value: 'state value',
label: 'State Label',
options: { values: ['state value'] }
}
})
})
it('deduplication keeps visible widget over hidden duplicate', () => {
const sharedEntityId = widgetEntityId(GRAPH_ID, '1', 'text')
const sharedWidgetId = widgetId(GRAPH_ID, '1', 'text')
const hiddenWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: '1',
entityId: sharedEntityId,
slotName: 'text',
widgetId: sharedWidgetId,
options: { hidden: true }
})
@@ -329,8 +405,7 @@ describe('computeProcessedWidgets borderStyle', () => {
name: 'text',
type: 'combo',
nodeId: '1',
entityId: sharedEntityId,
slotName: 'text'
widgetId: sharedWidgetId
})
const result = computeProcessedWidgets({
@@ -391,7 +466,7 @@ describe('computeProcessedWidgets borderStyle', () => {
expect(result).toHaveLength(1)
expect(result[0].name).toBe('color')
expect(result[0].renderKey).toBe('node:1:color:color:color')
expect(result[0].renderKey).toBe('node:1:color:color')
})
})
@@ -432,9 +507,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
callback
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, NODE_ID, 'seed'), {
type: 'combo',
value: 0,
options: {}
@@ -466,9 +539,7 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
nodeId: NODE_ID
})
useWidgetValueStore().registerWidget(GRAPH_ID, {
nodeId: NODE_ID,
name: 'seed',
useWidgetValueStore().registerWidget(widgetId(GRAPH_ID, NODE_ID, 'seed'), {
type: 'combo',
value: 0,
options: {}
@@ -477,7 +548,9 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
const [processed] = processWidgets([widget])
processed.updateHandler(99)
const state = useWidgetValueStore().getWidget(GRAPH_ID, NODE_ID, 'seed')
const state = useWidgetValueStore().getWidget(
widgetId(GRAPH_ID, NODE_ID, 'seed')
)
expect(state?.value).toBe(99)
})

View File

@@ -31,8 +31,8 @@ import {
} from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { getWidgetState } from '@/world/widgetValueIO'
import type { WidgetId } from '@/world/entityIds'
import { widgetId } from '@/types/widgetId'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type {
LinkedUpstreamInfo,
@@ -51,7 +51,7 @@ interface ProcessedWidget {
hasError: boolean
hidden: boolean
id: string
entityId?: WidgetEntityId
widgetId?: WidgetId
name: string
renderKey: string
simplified: SimplifiedWidget
@@ -90,8 +90,8 @@ function createWidgetUpdateHandler(
const effectiveExecId = widget.sourceExecutionId ?? nodeExecId
executionErrorStore.clearWidgetRelatedErrors(
effectiveExecId,
widget.slotName ?? widget.name,
widget.name,
widget.sourceWidgetName ?? widget.name,
newValue,
{ min: widgetOptions?.min, max: widgetOptions?.max }
)
@@ -110,12 +110,11 @@ export function hasWidgetError(
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
const inputName = widget.slotName ?? widget.name
return (
!!errors?.some((e) => e.extra_info?.input_name === inputName) ||
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(
widget.sourceExecutionId ?? nodeExecId,
widget.name
widget.sourceWidgetName ?? widget.name
)
)
}
@@ -128,11 +127,10 @@ export function getWidgetIdentity(
dedupeIdentity?: string
renderKey: string
} {
if (widget.entityId) {
const dedupeIdentity = `${widget.entityId}:${widget.type}`
if (widget.widgetId) {
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
return { dedupeIdentity, renderKey: dedupeIdentity }
}
const slotNameForIdentity = widget.slotName ?? widget.name
const hostNodeIdRoot =
nodeId !== undefined && nodeId !== ''
? `node:${String(stripGraphPrefix(nodeId))}`
@@ -144,11 +142,11 @@ export function getWidgetIdentity(
: hostNodeIdRoot
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${widget.name}:${slotNameForIdentity}:${widget.type}`
? `${stableIdentityRoot}:${widget.name}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${widget.name}:${slotNameForIdentity}:${widget.type}:${index}`
`transient:${String(nodeId ?? '')}:${widget.name}:${widget.type}:${index}`
return { dedupeIdentity, renderKey }
}
@@ -198,13 +196,15 @@ export function computeProcessedWidgets({
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const widgetState = widget.entityId
? getWidgetState(widget.entityId)
const widgetState = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: graphId
? widgetValueStore.getWidget(
graphId,
String(stripGraphPrefix(widget.nodeId ?? nodeId ?? '')),
widget.name
widgetId(
graphId,
String(stripGraphPrefix(widget.nodeId ?? nodeId ?? '')),
widget.name
)
)
: undefined
const mergedOptions: IWidgetOptions = {
@@ -288,13 +288,13 @@ export function computeProcessedWidgets({
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
name: widgetState?.name ?? widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widget.promotedLabel ?? widgetState?.label,
label: widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
@@ -336,7 +336,7 @@ export function computeProcessedWidgets({
),
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
entityId: widget.entityId,
widgetId: widget.widgetId,
name: widget.name,
renderKey,
type: widget.type,

View File

@@ -15,6 +15,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
// Consolidate with useStringWidget into shared helpers (domWidgetHelpers.ts).
@@ -52,14 +53,18 @@ function addMarkdownWidget(
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
const storedValue = widgetStore.getWidget(
widgetId(graphId, node.id, name)
)?.value
return typeof storedValue === 'string' ? storedValue : textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const widgetState = widgetStore.getWidget(
widgetId(graphId, node.id, name)
)
if (widgetState) widgetState.value = v
}
})

View File

@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
@@ -39,16 +40,16 @@ export function useTextPreviewWidget(
options: {
getValue: () =>
useWidgetValueStore().getWidget(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputSpec.name
widgetId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputSpec.name
)
)?.value ?? '',
setValue: (value: string | object) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
graphId,
node.id,
inputSpec.name
widgetId(graphId, node.id, inputSpec.name)
)
if (widgetState)
widgetState.value =

View File

@@ -9,6 +9,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
const TRACKPAD_DETECTION_THRESHOLD = 50
@@ -30,14 +31,18 @@ function addMultilineWidget(
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const widgetState = widgetStore.getWidget(
widgetId(graphId, node.id, name)
)
return (widgetState?.value as string) ?? inputEl.value
},
setValue(v: string) {
inputEl.value = v
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = widgetStore.getWidget(graphId, node.id, name)
const widgetState = widgetStore.getWidget(
widgetId(graphId, node.id, name)
)
if (widgetState) widgetState.value = v
}
})

View File

@@ -1,8 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { 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 { resolveWidgetFromHostNode } from '@/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget'
@@ -13,27 +11,6 @@ class TestNode extends LGraphNode {
}
}
class TestSubgraphNode extends TestNode {
constructor(
widgets: IBaseWidget[],
innerNodesById: Record<string, LGraphNode> = {}
) {
super(widgets)
this.subgraph = {
getNodeById: (nodeId: string) => innerNodesById[nodeId]
} as SubgraphNode['subgraph']
}
override isSubgraphNode(): this is SubgraphNode {
return true
}
readonly subgraph: SubgraphNode['subgraph']
}
type TestPromotedWidget = IBaseWidget &
Pick<PromotedWidgetView, 'sourceNodeId' | 'sourceWidgetName'>
function createWidget(name: string): IBaseWidget {
return {
name,
@@ -43,131 +20,24 @@ function createWidget(name: string): IBaseWidget {
}
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string
): TestPromotedWidget {
return {
...createWidget(name),
sourceNodeId,
sourceWidgetName
}
}
function createHostNode(
widgets: IBaseWidget[],
options: {
isSubgraphNode?: boolean
innerNodesById?: Record<string, LGraphNode>
} = {}
): LGraphNode {
const { isSubgraphNode = false, innerNodesById = {} } = options
return isSubgraphNode
? new TestSubgraphNode(widgets, innerNodesById)
: new TestNode(widgets)
}
describe('resolveWidgetFromHostNode', () => {
it('returns host node widget for non-promoted widgets', () => {
it('returns the host node widget matching the name', () => {
const widget = createWidget('text_widget')
const hostNode = createHostNode([widget])
const hostNode = new TestNode([widget])
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
expect(resolved).toEqual({ node: hostNode, widget })
expect(resolveWidgetFromHostNode(hostNode, widget.name)).toEqual({
node: hostNode,
widget
})
})
it('resolves promoted widget to the interior node widget', () => {
const innerWidget = createWidget('inner_text')
const innerNode = createHostNode([innerWidget])
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true,
innerNodesById: { '42': innerNode }
})
it('returns undefined when no widget matches the name', () => {
const hostNode = new TestNode([createWidget('other')])
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
expect(resolveWidgetFromHostNode(hostNode, 'missing')).toBeUndefined()
})
it('resolves nested promoted widget chain to deepest interior widget', () => {
const innerWidget = createWidget('inner_text')
const innerNode = createHostNode([innerWidget])
const middleNode = createHostNode([], {
isSubgraphNode: true,
innerNodesById: { '100': innerNode }
})
const middlePromotedWidget = {
...createPromotedWidget('inner_text', '100', 'inner_text'),
node: middleNode
} as TestPromotedWidget & { node: LGraphNode }
middleNode.widgets = [middlePromotedWidget]
const outerPromotedWidget = createPromotedWidget(
'outer_text',
'42',
'inner_text'
)
const hostNode = createHostNode([outerPromotedWidget], {
isSubgraphNode: true,
innerNodesById: { '42': middleNode }
})
const resolved = resolveWidgetFromHostNode(
hostNode,
outerPromotedWidget.name
)
expect(resolved).toEqual({ node: innerNode, widget: innerWidget })
})
it('returns undefined when promoted interior node is missing', () => {
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true
})
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toBeUndefined()
})
it('returns undefined when promoted interior widget is missing', () => {
const innerNode = createHostNode([])
const promotedWidget = createPromotedWidget(
'promoted_text',
'42',
'inner_text'
)
const hostNode = createHostNode([promotedWidget], {
isSubgraphNode: true,
innerNodesById: { '42': innerNode }
})
const resolved = resolveWidgetFromHostNode(hostNode, promotedWidget.name)
expect(resolved).toBeUndefined()
})
it('treats promoted-shaped widgets on non-subgraph nodes as local widgets', () => {
const widget = createPromotedWidget('promoted_text', '42', 'inner_text')
const hostNode = createHostNode([widget], {
isSubgraphNode: false
})
const resolved = resolveWidgetFromHostNode(hostNode, widget.name)
expect(resolved).toEqual({ node: hostNode, widget })
it('returns undefined when the host node is undefined', () => {
expect(resolveWidgetFromHostNode(undefined, 'anything')).toBeUndefined()
})
})

View File

@@ -1,25 +1,19 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export interface ResolvedHostWidget {
node: LGraphNode
widget: IBaseWidget
}
export function resolveWidgetFromHostNode(
hostNode: LGraphNode | undefined,
widgetName: string
): ResolvedPromotedWidget | undefined {
): ResolvedHostWidget | undefined {
if (!hostNode) return undefined
const widget = hostNode.widgets?.find((entry) => entry.name === widgetName)
if (!widget) return undefined
const sourceWidget = resolvePromotedWidgetSource(hostNode, widget)
if (sourceWidget) return sourceWidget
if (isPromotedWidgetView(widget) && hostNode.isSubgraphNode())
return undefined
return {
node: hostNode,
widget
}
return { node: hostNode, widget }
}

View File

@@ -78,8 +78,8 @@ vi.mock('@/stores/nodeOutputStore', () => ({
vi.mock('@/stores/widgetValueStore', () => {
const widgetMap = new Map<string, { value: unknown }>()
const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) =>
widgetMap.get(name)
const getWidget = vi.fn((id: string) =>
widgetMap.get(id.split(':').slice(2).join(':'))
)
return {
useWidgetValueStore: () => ({

View File

@@ -8,6 +8,7 @@ import type { UUID } from '@/utils/uuid'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import { curveDataToFloatLUT } from '@/components/curve/curveUtils'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
@@ -194,17 +195,14 @@ function createInnerPreview(
if (isGLSLNode.value) {
const nId = nodeId.value
if (nId == null) return undefined
return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as
| string
| undefined
return widgetValueStore.getWidget(widgetId(gId, nId, 'fragment_shader'))
?.value as string | undefined
}
const inner = innerGLSLNode
if (inner) {
return widgetValueStore.getWidget(
gId,
inner.id as NodeId,
'fragment_shader'
widgetId(gId, inner.id as NodeId, 'fragment_shader')
)?.value as string | undefined
}
@@ -292,21 +290,15 @@ function createInnerPreview(
if (sizeModeNodeId == null) return null
const sizeMode = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode'
widgetId(gId, sizeModeNodeId, 'size_mode')
)
if (sizeMode?.value !== 'custom') return null
const widthWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.width'
widgetId(gId, sizeModeNodeId, 'size_mode.width')
)
const heightWidget = widgetValueStore.getWidget(
gId,
sizeModeNodeId,
'size_mode.height'
widgetId(gId, sizeModeNodeId, 'size_mode.height')
)
if (!widthWidget || !heightWidget) return null

View File

@@ -11,6 +11,7 @@ import { isCurveData } from '@/components/curve/curveUtils'
import type { CurveData } from '@/components/curve/types'
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
import { hexToInt } from '@/utils/colorUtil'
import { widgetId } from '@/types/widgetId'
interface AutogrowGroup {
max: number
@@ -132,7 +133,9 @@ export function useGLSLUniforms(
if (subgraphSources) {
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
const widget = widgetValueStore.getWidget(
widgetId(gId, nId, widgetName)
)
return coerce(widget?.value ?? directValue() ?? defaultValue)
})
}
@@ -144,7 +147,7 @@ export function useGLSLUniforms(
const values: T[] = []
for (let i = 0; i < maxCount; i++) {
const inputName = `${groupName}.${uniformPrefix}${i}`
const widget = widgetValueStore.getWidget(gId, nId, inputName)
const widget = widgetValueStore.getWidget(widgetId(gId, nId, inputName))
if (widget !== undefined) {
values.push(coerce(widget.value))
continue
@@ -214,7 +217,9 @@ export function useGLSLUniforms(
if (sources && sources.length > 0) {
return sources
.map(({ nodeId: nId, widgetName, directValue }) => {
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
const widget = widgetValueStore.getWidget(
widgetId(gId, nId, widgetName)
)
const value = widget?.value ?? directValue()
return isCurveData(value) ? (value as CurveData) : null
})
@@ -230,7 +235,7 @@ export function useGLSLUniforms(
for (let i = 0; i < max; i++) {
const inputName = `curves.u_curve${i}`
const widget = widgetValueStore.getWidget(gId, nId, inputName)
const widget = widgetValueStore.getWidget(widgetId(gId, nId, inputName))
if (widget && isCurveData(widget.value)) {
values.push(widget.value as CurveData)
continue

View File

@@ -5,8 +5,17 @@ import { nextTick } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
LGraphNode as LGraphNodeClass,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { widgetId } from '@/world/entityIds'
import type {
InputWidgetConfig,
LinearInput,
@@ -17,7 +26,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import type { WidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
@@ -127,16 +136,16 @@ function createWorkflowWithLinearData(
}
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entityPrompt = `${rootGraphId}:1:prompt` as WidgetEntityId
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
const entitySteps = `${rootGraphId}:1:steps` as WidgetEntityId
const entityPrompt = `${rootGraphId}:1:prompt` as WidgetId
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
const entitySteps = `${rootGraphId}:1:steps` as WidgetId
function nodeWithWidgets(id: number, widgetNames: string[]) {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
}))
})
}
@@ -660,10 +669,10 @@ describe('appModeStore', () => {
})
describe('updateInputConfig', () => {
const entity = 'g:1:prompt' as WidgetEntityId
const otherEntity = 'g:99:prompt' as WidgetEntityId
const widget = fromAny<IBaseWidget, unknown>({ entityId: entity })
const otherWidget = fromAny<IBaseWidget, unknown>({ entityId: otherEntity })
const entity = 'g:1:prompt' as WidgetId
const otherEntity = 'g:99:prompt' as WidgetId
const widget = fromAny<IBaseWidget, unknown>({ widgetId: entity })
const otherWidget = fromAny<IBaseWidget, unknown>({ widgetId: otherEntity })
it('sets config on an existing input', () => {
store.selectedInputs.push([entity, 'prompt'])
@@ -681,11 +690,11 @@ describe('appModeStore', () => {
expect(store.selectedInputs[0][2]).toBeUndefined()
})
it('is a no-op when the widget has no entityId', () => {
it('is a no-op when the widget has no widgetId', () => {
store.selectedInputs.push([entity, 'prompt'])
store.updateInputConfig(
fromAny<IBaseWidget, unknown>({ entityId: undefined }),
fromAny<IBaseWidget, unknown>({ widgetId: undefined }),
{ height: 200 }
)
@@ -721,10 +730,10 @@ describe('appModeStore', () => {
describe('removeSelectedInput', () => {
it('removes the matching input entry only', () => {
const promptEntity = 'g:1:prompt' as WidgetEntityId
const stepsEntity = 'g:2:steps' as WidgetEntityId
const promptEntity = 'g:1:prompt' as WidgetId
const stepsEntity = 'g:2:steps' as WidgetId
const stepsWidget = fromAny<IBaseWidget, unknown>({
entityId: stepsEntity,
widgetId: stepsEntity,
name: 'steps'
})
store.selectedInputs.push([promptEntity, 'prompt'])
@@ -804,33 +813,38 @@ describe('appModeStore', () => {
const rootGraphId = '11111111-1111-4111-8111-111111111111'
it('migrates legacy `(sourceNodeId, sourceWidgetName)` to the host promoted widget entity id', () => {
const hostId = 5
const sourceNodeId = 42
const subgraphInputName = 'Prompt'
const sourceWidgetName = 'text'
const promotedEntityId =
`${rootGraphId}:${hostId}:${subgraphInputName}` as WidgetEntityId
const hostWidget = {
name: subgraphInputName,
sourceNodeId: String(sourceNodeId),
sourceWidgetName,
entityId: promotedEntityId
}
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]
const subgraph = createTestSubgraph({
inputs: [{ name: subgraphInputName, type: 'STRING' }]
})
const interior = new LGraphNodeClass('Interior')
const interiorInput = interior.addInput(subgraphInputName, 'STRING')
interior.addWidget('string', sourceWidgetName, '', () => undefined)
interiorInput.widget = { name: sourceWidgetName }
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interiorInput, interior)
const host = createTestSubgraphNode(subgraph, { id: 5 })
const rootGraph = host.graph as LGraph
rootGraph.add(host)
host._internalConfigureAfterSlots()
const promotedEntityId = widgetId(
rootGraph.id,
host.id,
subgraphInputName
)
vi.mocked(app.rootGraph).id = rootGraph.id
vi.mocked(app.rootGraph).nodes = rootGraph.nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id: NodeId | null | undefined) => (id == hostId ? hostNode : null)
(id: NodeId | null | undefined) => rootGraph.getNodeById(id as NodeId)
)
const result = store.pruneLinearData({
inputs: [[sourceNodeId, sourceWidgetName, { height: 120 }]],
inputs: [[interior.id, sourceWidgetName, { height: 120 }]],
outputs: []
})
@@ -844,16 +858,16 @@ describe('appModeStore', () => {
const sourceNodeId = 42
const sourceWidgetName = 'text'
const rootEntityId =
`${rootGraphId}:${sourceNodeId}:${sourceWidgetName}` as WidgetEntityId
`${rootGraphId}:${sourceNodeId}:${sourceWidgetName}` as WidgetId
const rootNode = fromAny<LGraphNode, unknown>({
id: sourceNodeId,
widgets: [{ name: sourceWidgetName, entityId: rootEntityId }]
widgets: [{ name: sourceWidgetName, widgetId: rootEntityId }]
})
const hostWidget = {
name: 'Prompt',
sourceNodeId: String(sourceNodeId),
sourceWidgetName,
entityId: `${rootGraphId}:${hostId}:Prompt` as WidgetEntityId
widgetId: `${rootGraphId}:${hostId}:Prompt` as WidgetId
}
const hostNode = Object.assign(Object.create(SubgraphNode.prototype), {
id: hostId,
@@ -907,11 +921,11 @@ describe('appModeStore', () => {
const hostId = 5
const hostLocator = `${rootGraphId}:${hostId}`
const promotedEntityId =
`${rootGraphId}:${hostId}:subgraph_input_name` as WidgetEntityId
`${rootGraphId}:${hostId}:subgraph_input_name` as WidgetId
const hostNode = fromAny<LGraphNode, unknown>({
id: hostId,
isSubgraphNode: () => true,
widgets: [{ name: 'subgraph_input_name', entityId: promotedEntityId }]
widgets: [{ name: 'subgraph_input_name', widgetId: promotedEntityId }]
})
vi.mocked(app.rootGraph).id = rootGraphId
vi.mocked(app.rootGraph).nodes = [hostNode]

View File

@@ -17,24 +17,24 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
getWidgetEntityIdForNode,
getWidgetIdForNode,
resolveNode,
resolveNodeWidget
} from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import { isWidgetId, parseWidgetId } from '@/world/entityIds'
function findWidgetByEntityId(
rootGraph: LGraph,
entityId: WidgetEntityId
widgetId: WidgetId
): IBaseWidget | undefined {
for (const node of rootGraph.nodes) {
const widget = node.widgets?.find(
(w) => getWidgetEntityIdForNode(node, w) === entityId
(w) => getWidgetIdForNode(node, w) === widgetId
)
if (widget) return widget
}
@@ -82,11 +82,11 @@ export const useAppModeStore = defineStore('appMode', () => {
}
function buildEntry(
entityId: WidgetEntityId,
widgetId: WidgetId,
name: string,
config: InputWidgetConfig | undefined
): LinearInput {
return config === undefined ? [entityId, name] : [entityId, name, config]
return config === undefined ? [widgetId, name] : [widgetId, name, config]
}
function upgradeAndValidateInput(
@@ -95,10 +95,10 @@ export const useAppModeStore = defineStore('appMode', () => {
): LinearInput | null {
const [storedId, widgetName, config] = input
if (typeof storedId === 'string' && isWidgetEntityId(storedId)) {
if (typeof storedId === 'string' && isWidgetId(storedId)) {
const widget = findWidgetByEntityId(rootGraph, storedId)
if (widget) return buildEntry(storedId, widgetName, config)
const { nodeId } = parseWidgetEntityId(storedId)
const { nodeId } = parseWidgetId(storedId)
if (rootGraph.getNodeById?.(nodeId)) {
return buildEntry(storedId, widgetName, config)
}
@@ -107,31 +107,29 @@ export const useAppModeStore = defineStore('appMode', () => {
if (typeof storedId === 'string' && storedId.includes(':')) {
const [, widget] = resolveNodeWidget(storedId, widgetName)
if (!widget?.entityId) return null
return buildEntry(widget.entityId, widgetName, config)
if (!widget?.widgetId) return null
return buildEntry(widget.widgetId, widgetName, config)
}
const directNode = rootGraph.getNodeById?.(storedId)
const directWidget = directNode?.widgets?.find((w) => w.name === widgetName)
if (directNode && directWidget) {
const derivedId = getWidgetEntityIdForNode(directNode, directWidget)
const derivedId = getWidgetIdForNode(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 (!inputSlot.widgetId) return []
const target = resolveSubgraphInputTarget(node, inputSlot.name)
if (
widget.sourceNodeId !== String(storedId) ||
widget.sourceWidgetName !== widgetName
target?.nodeId !== String(storedId) ||
target.widgetName !== widgetName
) {
return []
}
return widget.entityId
? [buildEntry(widget.entityId, inputSlot.name, config)]
: []
return [buildEntry(inputSlot.widgetId, inputSlot.name, config)]
})
})
if (matches.length === 1) return matches[0]
@@ -246,7 +244,7 @@ export const useAppModeStore = defineStore('appMode', () => {
}
function removeSelectedInput(widget: IBaseWidget) {
const targetEntityId = widget.entityId
const targetEntityId = widget.widgetId
if (!targetEntityId) return
const index = selectedInputs.value.findIndex(
([id]) => id === targetEntityId
@@ -255,7 +253,7 @@ export const useAppModeStore = defineStore('appMode', () => {
}
function updateInputConfig(widget: IBaseWidget, config: InputWidgetConfig) {
const targetEntityId = widget.entityId
const targetEntityId = widget.widgetId
if (!targetEntityId) return
const index = selectedInputs.value.findIndex(
([id]) => id === targetEntityId

View File

@@ -2,6 +2,13 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { NodeDefFilter } from '@/stores/nodeDefStore'
@@ -331,6 +338,59 @@ describe('useNodeDefStore', () => {
})
})
describe('subgraph widget input specs', () => {
function setupPromotedPrompt(nodeDef: ComfyNodeDef): SubgraphNode {
store.updateNodeDefs([nodeDef])
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const graph = host.graph as LGraph
graph.add(host)
const interior = new LGraphNode('PromptNode', 'PromptNode')
const input = interior.addInput('prompt', 'STRING')
const widget = interior.addWidget(
'text',
'prompt',
'current edited value',
() => undefined
)
input.widget = { name: widget.name }
subgraph.add(interior)
promoteValueWidgetViaSubgraphInput(host, interior, widget)
return host
}
it('resolves the interior node definition spec, not the current value', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: {
required: { prompt: ['STRING', { default: 'real default' }] }
}
})
)
expect(store.getInputSpecForWidget(host, 'prompt')).toMatchObject({
name: 'prompt',
type: 'STRING',
default: 'real default'
})
})
it('does not fabricate a default from the current promoted value', () => {
const host = setupPromotedPrompt(
createMockNodeDef({
name: 'PromptNode',
input: { required: { prompt: ['STRING', {}] } }
})
)
const spec = store.getInputSpecForWidget(host, 'prompt')
expect(spec?.type).toBe('STRING')
expect(spec?.default).toBeUndefined()
})
})
describe('performance', () => {
it('should perform single traversal for multiple filters', () => {
let filterCallCount = 0

View File

@@ -4,8 +4,8 @@ import { defineStore } from 'pinia'
import { computed, ref, watchEffect } from 'vue'
import { t } from '@/i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { promotedInputSource } from '@/core/graph/subgraph/promotedInputWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveInputType } from '@/core/graph/widgets/dynamicTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -427,13 +427,22 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
return nodeDef.inputs[widgetName]
}
const widget = node.widgets?.find((w) => w.name === widgetName)
if (!widget || !isPromotedWidgetView(widget)) return undefined
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) return undefined
return getInputSpecForWidget(sourceWidget.node, sourceWidget.widget.name)
// A subgraph node's widget is a promoted input named after its slot; resolve
// the interior source and read its real spec instead of fabricating one.
const input = node.inputs.find((i) => i.name === widgetName)
if (!input) return undefined
const source = promotedInputSource(node, input)
if (!source) return undefined
const resolution = resolveConcretePromotedWidget(
node,
source.nodeId,
source.widgetName
)
if (resolution.status !== 'resolved') return undefined
return getInputSpecForWidget(
resolution.resolved.node,
resolution.resolved.widget.name
)
}
/**

View File

@@ -3,24 +3,25 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { UUID } from '@/utils/uuid'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from './widgetValueStore'
import { useWidgetValueStore } from './widgetValueStore'
function widget<T>(
nodeId: string,
name: string,
function state<T>(
type: string,
value: T,
extra: Partial<
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
> = {}
): WidgetState<T> {
return { nodeId, name, type, value, options: {}, ...extra }
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
): Omit<WidgetState<T>, 'nodeId' | 'name'> {
return { type, value, options: {}, ...extra }
}
describe('useWidgetValueStore', () => {
const graphA = 'graph-a' as UUID
const graphB = 'graph-b' as UUID
const seedA = widgetId(graphA, 'node-1', 'seed')
const seedB = widgetId(graphB, 'node-1', 'seed')
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -28,62 +29,81 @@ describe('useWidgetValueStore', () => {
describe('widgetState.value access', () => {
it('getWidget returns undefined for unregistered widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
expect(store.getWidget(seedA)).toBeUndefined()
})
it('widgetState.value can be read and written directly', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
expect(state.value).toBe(100)
const registered = store.registerWidget(seedA, state('number', 100))
expect(registered.value).toBe(100)
state.value = 200
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
registered.value = 200
expect(store.getWidget(seedA)?.value).toBe(200)
})
it('stores different value types', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
store.registerWidget(
graphA,
widget('node-1', 'array', 'combo', [1, 2, 3])
widgetId(graphA, 'node-1', 'text'),
state('string', 'hello')
)
store.registerWidget(
widgetId(graphA, 'node-1', 'number'),
state('number', 42)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'boolean'),
state('toggle', true)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'array'),
state('combo', [1, 2, 3])
)
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
1, 2, 3
])
expect(store.getWidget(widgetId(graphA, 'node-1', 'text'))?.value).toBe(
'hello'
)
expect(store.getWidget(widgetId(graphA, 'node-1', 'number'))?.value).toBe(
42
)
expect(
store.getWidget(widgetId(graphA, 'node-1', 'boolean'))?.value
).toBe(true)
expect(
store.getWidget(widgetId(graphA, 'node-1', 'array'))?.value
).toEqual([1, 2, 3])
})
})
describe('widget registration', () => {
it('registers a widget with minimal properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 12345)
)
const registered = store.registerWidget(seedA, state('number', 12345))
expect(state.nodeId).toBe('node-1')
expect(state.name).toBe('seed')
expect(state.type).toBe('number')
expect(state.value).toBe(12345)
expect(state.disabled).toBeUndefined()
expect(state.serialize).toBeUndefined()
expect(state.options).toEqual({})
expect(registered.nodeId).toBe('node-1')
expect(registered.name).toBe('seed')
expect(registered.type).toBe('number')
expect(registered.value).toBe(12345)
expect(registered.disabled).toBeUndefined()
expect(registered.serialize).toBeUndefined()
expect(registered.options).toEqual({})
})
it('registerWidget is idempotent and does not overwrite existing state', () => {
const store = useWidgetValueStore()
const first = store.registerWidget(seedA, state('number', 11))
first.value = 99
const second = store.registerWidget(seedA, state('number', 11))
expect(second).toBe(first)
expect(second.value).toBe(99)
})
it('registers a widget with all properties', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'prompt', 'string', 'test', {
const registered = store.registerWidget(
seedA,
state('string', 'test', {
label: 'Prompt Text',
disabled: true,
serialize: false,
@@ -91,34 +111,38 @@ describe('useWidgetValueStore', () => {
})
)
expect(state.label).toBe('Prompt Text')
expect(state.disabled).toBe(true)
expect(state.serialize).toBe(false)
expect(state.options).toEqual({ multiline: true })
expect(registered.label).toBe('Prompt Text')
expect(registered.disabled).toBe(true)
expect(registered.serialize).toBe(false)
expect(registered.options).toEqual({ multiline: true })
})
})
describe('widget getters', () => {
it('getWidget returns widget state', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
store.registerWidget(seedA, state('number', 100))
const state = store.getWidget(graphA, 'node-1', 'seed')
expect(state).toBeDefined()
expect(state?.name).toBe('seed')
expect(state?.value).toBe(100)
})
it('getWidget returns undefined for missing widget', () => {
const store = useWidgetValueStore()
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
const registered = store.getWidget(seedA)
expect(registered).toBeDefined()
expect(registered?.name).toBe('seed')
expect(registered?.value).toBe(100)
})
it('getNodeWidgets returns all widgets for a node', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
store.registerWidget(
widgetId(graphA, 'node-1', 'seed'),
state('number', 1)
)
store.registerWidget(
widgetId(graphA, 'node-1', 'steps'),
state('number', 20)
)
store.registerWidget(
widgetId(graphA, 'node-2', 'cfg'),
state('number', 7)
)
const widgets = store.getNodeWidgets(graphA, 'node-1')
expect(widgets).toHaveLength(2)
@@ -126,54 +150,66 @@ describe('useWidgetValueStore', () => {
})
})
describe('value mutation', () => {
it('setValue updates registered widgets and reports missing widgets', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 100))
expect(store.setValue(seedA, 200)).toBe(true)
expect(store.getWidget(seedA)?.value).toBe(200)
expect(store.setValue(widgetId(graphA, 'missing', 'seed'), 1)).toBe(false)
})
it('deleteWidget removes registered widgets', () => {
const store = useWidgetValueStore()
store.registerWidget(seedA, state('number', 100))
expect(store.deleteWidget(seedA)).toBe(true)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.deleteWidget(seedA)).toBe(false)
})
})
describe('direct property mutation', () => {
it('disabled can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const registered = store.registerWidget(seedA, state('number', 100))
state.disabled = true
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
registered.disabled = true
expect(store.getWidget(seedA)?.disabled).toBe(true)
})
it('label can be set directly via getWidget', () => {
const store = useWidgetValueStore()
const state = store.registerWidget(
graphA,
widget('node-1', 'seed', 'number', 100)
)
const registered = store.registerWidget(seedA, state('number', 100))
state.label = 'Random Seed'
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
'Random Seed'
)
registered.label = 'Random Seed'
expect(store.getWidget(seedA)?.label).toBe('Random Seed')
state.label = undefined
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
registered.label = undefined
expect(store.getWidget(seedA)?.label).toBeUndefined()
})
})
describe('graph isolation', () => {
it('isolates widget states by graph', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(store.getWidget(seedA)?.value).toBe(1)
expect(store.getWidget(seedB)?.value).toBe(2)
})
it('clearGraph only removes one graph namespace', () => {
const store = useWidgetValueStore()
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
store.registerWidget(seedA, state('number', 1))
store.registerWidget(seedB, state('number', 2))
store.clearGraph(graphA)
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getWidget(seedB)?.value).toBe(2)
})
})
})

View File

@@ -3,93 +3,69 @@ import { reactive, ref } from 'vue'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/utils/uuid'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { parseWidgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetState, WidgetStateInit } from '@/types/widgetState'
type WidgetKey = `${NodeId}:${string}`
export type { WidgetState, WidgetStateInit } from '@/types/widgetState'
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
}
export interface WidgetState<
TValue = unknown,
TType extends string = string,
TOptions extends IWidgetOptions = IWidgetOptions
> extends Pick<
IBaseWidget<TValue, TType, TOptions>,
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
> {
nodeId: NodeId
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
const graphWidgetStates = ref(new Map<UUID, Map<WidgetId, WidgetState>>())
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
function getGraphWidgetStates(graphId: UUID): Map<WidgetId, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
if (widgetStates) return widgetStates
const nextWidgetStates = reactive(new Map<WidgetKey, WidgetState>())
const nextWidgetStates = reactive(new Map<WidgetId, WidgetState>())
graphWidgetStates.value.set(graphId, nextWidgetStates)
return nextWidgetStates
}
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
return `${nodeId}:${widgetName}`
}
/**
* @deprecated Use `ensureWidgetState(widget.entityId, init)` from
* `src/world/widgetValueIO.ts` — the branded `WidgetEntityId` prevents
* producer/consumer drift that loose triples allow.
*/
function registerWidget<TValue = unknown>(
graphId: UUID,
state: WidgetState<TValue>
widgetId: WidgetId,
init: WidgetStateInit<TValue>
): WidgetState<TValue> {
const widgetStates = getWidgetStateMap(graphId)
const key = makeKey(state.nodeId, state.name)
widgetStates.set(key, state)
return widgetStates.get(key) as WidgetState<TValue>
const existing = getWidget(widgetId)
if (existing) return existing as WidgetState<TValue>
const { graphId, nodeId, name } = parseWidgetId(widgetId)
const state: WidgetState<TValue> = {
...init,
nodeId,
name
}
const widgetStates = getGraphWidgetStates(graphId)
widgetStates.set(widgetId, state)
return widgetStates.get(widgetId) as WidgetState<TValue>
}
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
const widgetStates = getWidgetStateMap(graphId)
const prefix = `${nodeId}:`
return [...widgetStates]
.filter(([key]) => key.startsWith(prefix))
.map(([, state]) => state)
function getWidget(widgetId: WidgetId): WidgetState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetStates(graphId).get(widgetId)
}
/**
* @deprecated Use `getWidgetState(widget.entityId)` or
* `readWidgetValue(widget.entityId)` from `src/world/widgetValueIO.ts` —
* the branded `WidgetEntityId` prevents producer/consumer drift that loose
* triples allow.
*/
function getWidget(
graphId: UUID,
nodeId: NodeId,
widgetName: string
): WidgetState | undefined {
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
}
function setValue(
graphId: UUID,
nodeId: NodeId,
widgetName: string,
value: WidgetState['value']
): boolean {
const state = getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
function setValue(widgetId: WidgetId, value: WidgetState['value']): boolean {
const state = getWidget(widgetId)
if (!state) return false
state.value = value
return true
}
function deleteWidget(widgetId: WidgetId): boolean {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetStates(graphId).delete(widgetId)
}
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
return [...getGraphWidgetStates(graphId).values()].filter(
(state) => String(state.nodeId) === String(nodeId)
)
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
}
@@ -98,6 +74,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
registerWidget,
getWidget,
setValue,
deleteWidget,
getNodeWidgets,
clearGraph
}

51
src/types/widgetId.ts Normal file
View File

@@ -0,0 +1,51 @@
type NodeId = number | string
type UUID = string
export type WidgetId = string & { readonly __brand: 'WidgetId' }
const SEPARATOR = ':'
export function widgetId(
graphId: UUID,
nodeId: NodeId,
name: string
): WidgetId {
return [
graphId,
encodeURIComponent(String(nodeId)),
encodeURIComponent(name)
].join(SEPARATOR) as WidgetId
}
function decodeWidgetIdSegment(segment: string): string {
try {
return decodeURIComponent(segment)
} catch (error) {
if (error instanceof URIError) return segment
throw error
}
}
export function parseWidgetId(id: WidgetId): {
graphId: UUID
nodeId: NodeId
name: string
} {
const firstColon = id.indexOf(SEPARATOR)
const secondColon = id.indexOf(SEPARATOR, firstColon + 1)
const nodeId = id.slice(firstColon + 1, secondColon)
const name = id.slice(secondColon + 1)
return {
graphId: id.slice(0, firstColon),
nodeId: decodeWidgetIdSegment(nodeId),
name: decodeWidgetIdSegment(name)
}
}
export function isWidgetId(value: unknown): value is WidgetId {
if (typeof value !== 'string') return false
const firstColon = value.indexOf(SEPARATOR)
if (firstColon <= 0) return false
const secondColon = value.indexOf(SEPARATOR, firstColon + 1)
return secondColon > firstColon + 1
}

22
src/types/widgetState.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
export interface WidgetState<
TValue = unknown,
TType extends string = string,
TOptions extends IWidgetOptions = IWidgetOptions
> extends Pick<
IBaseWidget<TValue, TType, TOptions>,
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
> {
isDOMWidget?: boolean
nodeId: NodeId
}
export type WidgetStateInit<TValue = unknown> = Omit<
WidgetState<TValue>,
'nodeId' | 'name'
>

View File

@@ -3,14 +3,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import { widgetId } from '@/world/entityIds'
import {
createNode,
getWidgetEntityIdForNode,
resolveNode
} from './litegraphUtil'
import { createNode, getWidgetIdForNode, resolveNode } from './litegraphUtil'
const mockBringNodeToFront = vi.fn()
@@ -149,7 +145,7 @@ describe('createNode', () => {
})
})
describe('getWidgetEntityIdForNode', () => {
describe('getWidgetIdForNode', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
function fakeNode(id: number, opts: { detached?: boolean } = {}): LGraphNode {
@@ -159,29 +155,29 @@ describe('getWidgetEntityIdForNode', () => {
} as unknown as LGraphNode
}
it('returns widget.entityId when present', () => {
it('returns widget.widgetId when present', () => {
const node = fakeNode(7)
const widget = {
name: 'seed',
entityId: 'precomputed:7:seed' as WidgetEntityId
widgetId: 'precomputed:7:seed' as WidgetId
}
expect(getWidgetEntityIdForNode(node, widget)).toBe('precomputed:7:seed')
expect(getWidgetIdForNode(node, widget)).toBe('precomputed:7:seed')
})
it('derives an entityId for plain POJO widgets bound to a node', () => {
it('derives an widgetId for plain POJO widgets bound to a node', () => {
const node = fakeNode(42)
expect(getWidgetEntityIdForNode(node, { name: 'legacy_widget' })).toBe(
widgetEntityId(graphId, 42, 'legacy_widget')
expect(getWidgetIdForNode(node, { name: 'legacy_widget' })).toBe(
widgetId(graphId, 42, 'legacy_widget')
)
})
it('returns undefined when the node has no graph', () => {
const node = fakeNode(1, { detached: true })
expect(getWidgetEntityIdForNode(node, { name: 'x' })).toBeUndefined()
expect(getWidgetIdForNode(node, { name: 'x' })).toBeUndefined()
})
it('returns undefined for placeholder node id (-1)', () => {
const node = fakeNode(-1)
expect(getWidgetEntityIdForNode(node, { name: 'x' })).toBeUndefined()
expect(getWidgetIdForNode(node, { name: 'x' })).toBeUndefined()
})
})

View File

@@ -1,6 +1,5 @@
import _ from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ColorOption, LGraph } from '@/lib/litegraph/src/litegraph'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
@@ -28,8 +27,8 @@ import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNod
import { app } from '@/scripts/app'
import { t } from '@/i18n'
import { parseNodeLocatorId } from '@/types/nodeIdentification'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import type { WidgetId } from '@/world/entityIds'
import { widgetId } from '@/world/entityIds'
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
type VideoNode = LGraphNode & {
@@ -358,28 +357,17 @@ export function resolveNodeWidget(
return widget ? [node, widget] : []
}
for (const node of graph.nodes) {
if (!node.isSubgraphNode()) continue
const widget = node.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
if (widget) return [node, widget]
}
return []
}
export function getWidgetEntityIdForNode(
export function getWidgetIdForNode(
node: LGraphNode,
widget: Pick<IBaseWidget, 'name' | 'entityId'>
): WidgetEntityId | undefined {
if (widget.entityId) return widget.entityId
widget: Pick<IBaseWidget, 'name' | 'widgetId'>
): WidgetId | undefined {
if (widget.widgetId) return widget.widgetId
const graphId = node.graph?.rootGraph.id
if (!graphId || node.id === -1) return undefined
return widgetEntityId(graphId, node.id, widget.name)
return widgetId(graphId, node.id, widget.name)
}
export function isLoad3dNode(node: LGraphNode) {

View File

@@ -1,4 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -6,14 +8,8 @@ import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { getWidgetDefaultValue, renameWidget } from '@/utils/widgetUtil'
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
resolvePromotedWidgetSource: vi.fn()
}))
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
const mockedResolve = vi.mocked(resolvePromotedWidgetSource)
import type { WidgetId } from '@/world/entityIds'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('getWidgetDefaultValue', () => {
it('returns undefined for undefined spec', () => {
@@ -76,12 +72,18 @@ function makeNode({
describe('renameWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('renames a regular widget and its matching input', () => {
const widget = makeWidget({ name: 'seed' })
const input = { name: 'seed', widget: { name: 'seed' } } as INodeInputSlot
it('writes the label to the widget and the input matched by widgetId', () => {
const id = 'graph-id:1:seed' as WidgetId
const widget = makeWidget({ name: 'seed', widgetId: id })
const input = {
name: 'seed',
widgetId: id,
widget: { name: 'seed' }
} as INodeInputSlot
const node = makeNode({ inputs: [input] })
const result = renameWidget(widget, node, 'My Seed')
@@ -100,46 +102,45 @@ describe('renameWidget', () => {
expect(widget.label).toBeUndefined()
})
it('renames promoted widget source when node is a subgraph without explicit parents', () => {
const sourceWidget = makeWidget({ name: 'innerSeed' })
const interiorInput = {
name: 'innerSeed',
widget: { name: 'innerSeed' }
} as INodeInputSlot
const interiorNode = makeNode({ inputs: [interiorInput] })
mockedResolve.mockReturnValue({
widget: sourceWidget,
node: interiorNode
it('writes the label to host widget state for a promoted subgraph input', () => {
const hostWidgetId = 'graph-id:7:seed' as WidgetId
useWidgetValueStore().registerWidget(hostWidgetId, {
type: 'number',
value: 12,
options: {},
label: undefined
})
const promotedWidget = makeWidget({
const widget = makeWidget({ name: 'seed', widgetId: hostWidgetId })
const input = {
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const subgraphNode = makeNode({ isSubgraph: true })
widgetId: hostWidgetId,
widget: { name: 'seed' }
} as INodeInputSlot
const subgraphNode = makeNode({ isSubgraph: true, inputs: [input] })
const result = renameWidget(promotedWidget, subgraphNode, 'Renamed')
const result = renameWidget(widget, subgraphNode, 'Renamed')
expect(result).toBe(true)
expect(sourceWidget.label).toBe('Renamed')
expect(interiorInput.label).toBe('Renamed')
expect(promotedWidget.label).toBe('Renamed')
expect(useWidgetValueStore().getWidget(hostWidgetId)?.label).toBe('Renamed')
expect(input.label).toBe('Renamed')
expect(widget.label).toBe('Renamed')
})
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
const promotedWidget = makeWidget({
it('never mutates the widget or input name/widgetId (label-only)', () => {
const id = 'graph-id:7:seed' as WidgetId
const widget = makeWidget({ name: 'seed', widgetId: id })
const input = {
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const node = makeNode({ isSubgraph: false })
widgetId: id,
widget: { name: 'seed' }
} as INodeInputSlot
const subgraphNode = makeNode({ isSubgraph: true, inputs: [input] })
const result = renameWidget(promotedWidget, node, 'Renamed')
renameWidget(widget, subgraphNode, 'Display Only')
expect(result).toBe(true)
expect(mockedResolve).not.toHaveBeenCalled()
expect(promotedWidget.label).toBe('Renamed')
expect(widget.name).toBe('seed')
expect(widget.widgetId).toBe(id)
expect(input.name).toBe('seed')
expect(input.widgetId).toBe(id)
})
})

View File

@@ -1,5 +1,3 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -7,6 +5,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDialogService } from '@/services/dialogService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
export type WidgetValue = boolean | number | string | object | undefined
@@ -34,13 +33,13 @@ export function getWidgetDefaultValue(
}
/**
* Renames a widget and its corresponding input.
* Handles both regular widgets and promoted widget views in subgraphs.
* Renames a widget: a label-only write across the widget, its store state, and
* its backing input slot (matched by widgetId). Never touches the widget/input
* name or widgetId — those are the stable identity a promoted input derives from.
*
* @param widget The widget to rename
* @param node The node containing the widget
* @param newLabel The new label for the widget (empty string or undefined to clear)
* @param parents Optional array of parent SubgraphNodes (for promoted widgets)
* @returns true if the rename was successful, false otherwise
*/
export function renameWidget(
@@ -49,35 +48,18 @@ export function renameWidget(
newLabel: string,
parents?: SubgraphNode[]
): boolean {
if (
isPromotedWidgetView(widget) &&
(parents?.length || node.isSubgraphNode())
) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
return false
}
void parents
const label = newLabel || undefined
const input = widget.widgetId
? node.inputs?.find((inp) => inp.widgetId === widget.widgetId)
: undefined
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
: undefined
const originalWidget = sourceWidget.widget
const interiorNode = sourceWidget.node
originalWidget.label = newLabel || undefined
const interiorInput = interiorNode.inputs?.find(
(inp) => inp.widget?.name === originalWidget.name
)
if (interiorInput) {
interiorInput.label = newLabel || undefined
}
}
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
widget.label = newLabel || undefined
if (input) {
input.label = newLabel || undefined
}
widget.label = label
if (widgetState) widgetState.label = label
if (input) input.label = label
// Fires for all node types; listeners guard against non-subgraph nodes.
node.graph?.trigger('node:slot-label:changed', {

View File

@@ -1,8 +0,0 @@
/**
* @example
* ```ts
* type WidgetEntityId = Brand<string, 'WidgetEntityId'>
* const bad: WidgetEntityId = 'arbitrary string' // ❌ type error
* ```
*/
export type Brand<T, Tag extends string> = T & { readonly __brand: Tag }

View File

@@ -1,46 +1,40 @@
import { describe, expect, it } from 'vitest'
import type { WidgetEntityId } from './entityIds'
import {
isWidgetEntityId,
parseWidgetEntityId,
widgetEntityId
} from './entityIds'
import type { WidgetId } from './entityIds'
import { isWidgetId, parseWidgetId, widgetId } from './entityIds'
describe('widgetEntityId', () => {
describe('widgetId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
it('builds a deterministic id from its components', () => {
const id = widgetEntityId(graphId, 42, 'seed')
const id = widgetId(graphId, 42, 'seed')
expect(id).toBe(`${graphId}:42:seed`)
})
it('produces equal ids for equal inputs', () => {
expect(widgetEntityId(graphId, 42, 'seed')).toBe(
widgetEntityId(graphId, 42, 'seed')
)
expect(widgetId(graphId, 42, 'seed')).toBe(widgetId(graphId, 42, 'seed'))
})
it('produces distinct ids when any component differs', () => {
const baseline = widgetEntityId(graphId, 42, 'seed')
expect(widgetEntityId(graphId, 43, 'seed')).not.toBe(baseline)
expect(widgetEntityId(graphId, 42, 'steps')).not.toBe(baseline)
const baseline = widgetId(graphId, 42, 'seed')
expect(widgetId(graphId, 43, 'seed')).not.toBe(baseline)
expect(widgetId(graphId, 42, 'steps')).not.toBe(baseline)
const otherGraph = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
expect(widgetEntityId(otherGraph, 42, 'seed')).not.toBe(baseline)
expect(widgetId(otherGraph, 42, 'seed')).not.toBe(baseline)
})
it('accepts string node ids', () => {
const id = widgetEntityId(graphId, 'node-7', 'value')
const id = widgetId(graphId, 'node-7', 'value')
expect(id).toBe(`${graphId}:node-7:value`)
})
})
describe('parseWidgetEntityId', () => {
describe('parseWidgetId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
it('round-trips a constructed id', () => {
const id = widgetEntityId(graphId, 42, 'seed')
expect(parseWidgetEntityId(id)).toEqual({
const id = widgetId(graphId, 42, 'seed')
expect(parseWidgetId(id)).toEqual({
graphId,
nodeId: '42',
name: 'seed'
@@ -49,8 +43,8 @@ describe('parseWidgetEntityId', () => {
it('preserves colons inside the name segment', () => {
const rawName = 'nested:label:with:colons'
const rawId = `${graphId}:42:${rawName}` as WidgetEntityId
expect(parseWidgetEntityId(rawId)).toEqual({
const rawId = `${graphId}:42:${rawName}` as WidgetId
expect(parseWidgetId(rawId)).toEqual({
graphId,
nodeId: '42',
name: rawName
@@ -58,24 +52,24 @@ describe('parseWidgetEntityId', () => {
})
})
describe('isWidgetEntityId', () => {
describe('isWidgetId', () => {
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
it('accepts ids built by the constructor', () => {
expect(isWidgetEntityId(widgetEntityId(graphId, 1, 'x'))).toBe(true)
expect(isWidgetId(widgetId(graphId, 1, 'x'))).toBe(true)
})
it('rejects strings without two colon-separated segments', () => {
expect(isWidgetEntityId('only-one-colon:42')).toBe(false)
expect(isWidgetEntityId('no-colons')).toBe(false)
expect(isWidgetEntityId(':leading-colon:name')).toBe(false)
expect(isWidgetEntityId('graph::name')).toBe(false)
expect(isWidgetId('only-one-colon:42')).toBe(false)
expect(isWidgetId('no-colons')).toBe(false)
expect(isWidgetId(':leading-colon:name')).toBe(false)
expect(isWidgetId('graph::name')).toBe(false)
})
it('rejects non-strings', () => {
expect(isWidgetEntityId(42)).toBe(false)
expect(isWidgetEntityId(null)).toBe(false)
expect(isWidgetEntityId(undefined)).toBe(false)
expect(isWidgetEntityId({})).toBe(false)
expect(isWidgetId(42)).toBe(false)
expect(isWidgetId(null)).toBe(false)
expect(isWidgetId(undefined)).toBe(false)
expect(isWidgetId({})).toBe(false)
})
})

View File

@@ -1,39 +1,4 @@
import type { UUID } from '@/utils/uuid'
import type { Brand } from './brand'
export type NodeId = number | string
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
const SEPARATOR = ':'
export function widgetEntityId(
graphId: UUID,
nodeId: NodeId,
name: string
): WidgetEntityId {
return `${graphId}${SEPARATOR}${nodeId}${SEPARATOR}${name}` as WidgetEntityId
}
export function parseWidgetEntityId(id: WidgetEntityId): {
graphId: UUID
nodeId: NodeId
name: string
} {
const firstColon = id.indexOf(SEPARATOR)
const secondColon = id.indexOf(SEPARATOR, firstColon + 1)
return {
graphId: id.slice(0, firstColon),
nodeId: id.slice(firstColon + 1, secondColon),
name: id.slice(secondColon + 1)
}
}
export function isWidgetEntityId(value: unknown): value is WidgetEntityId {
if (typeof value !== 'string') return false
const firstColon = value.indexOf(SEPARATOR)
if (firstColon <= 0) return false
const secondColon = value.indexOf(SEPARATOR, firstColon + 1)
return secondColon > firstColon + 1
}
export type { WidgetId } from '@/types/widgetId'
export { isWidgetId, parseWidgetId, widgetId } from '@/types/widgetId'

View File

@@ -1,164 +0,0 @@
import { setActivePinia, createPinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetEntityId } from './entityIds'
import {
ensureWidgetState,
getWidgetState,
readWidgetValue,
writeWidgetValue
} from './widgetValueIO'
describe('widgetValueIO', () => {
const graphA = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
const graphB = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
beforeEach(() => {
setActivePinia(createPinia())
})
describe('ensureWidgetState', () => {
it('registers a new state when none exists', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const state = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(state.value).toBe(11)
expect(state.nodeId).toBe('1')
expect(state.name).toBe('seed')
})
it('is idempotent — returns the same state on repeated calls', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const init = {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
}
const first = ensureWidgetState(id, init)
const second = ensureWidgetState(id, init)
expect(second).toBe(first)
})
it('does not overwrite an existing state with init values', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const first = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
first.value = 99
const second = ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(second.value).toBe(99)
})
})
describe('readWidgetValue / writeWidgetValue', () => {
it('round-trips a value through the entity-id surface', () => {
const id = widgetEntityId(graphA, 1, 'seed')
ensureWidgetState(id, {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
})
expect(readWidgetValue(id)).toBe(11)
expect(writeWidgetValue(id, 22)).toBe(true)
expect(readWidgetValue(id)).toBe(22)
})
it('returns false when writing to an unregistered id', () => {
const id = widgetEntityId(graphA, 1, 'seed')
expect(writeWidgetValue(id, 22)).toBe(false)
})
it('returns undefined when reading an unregistered id', () => {
const id = widgetEntityId(graphA, 1, 'seed')
expect(readWidgetValue(id)).toBeUndefined()
expect(getWidgetState(id)).toBeUndefined()
})
})
describe('isolation', () => {
it('keeps independent values across distinct entity ids', () => {
const id1 = widgetEntityId(graphA, 1, 'seed')
const id2 = widgetEntityId(graphA, 2, 'seed')
const init = {
type: 'number',
value: 0,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(id1, init)
ensureWidgetState(id2, init)
writeWidgetValue(id1, 11)
writeWidgetValue(id2, 22)
expect(readWidgetValue(id1)).toBe(11)
expect(readWidgetValue(id2)).toBe(22)
})
it('isolates values across graph ids', () => {
const idA = widgetEntityId(graphA, 1, 'seed')
const idB = widgetEntityId(graphB, 1, 'seed')
const init = {
type: 'number',
value: 0,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(idA, init)
ensureWidgetState(idB, init)
writeWidgetValue(idA, 11)
writeWidgetValue(idB, 22)
expect(readWidgetValue(idA)).toBe(11)
expect(readWidgetValue(idB)).toBe(22)
})
it('matches the legacy triple-keyed API for the same widget', () => {
const id = widgetEntityId(graphA, 1, 'seed')
const init = {
type: 'number',
value: 11,
options: {},
label: undefined,
serialize: true,
disabled: false
}
ensureWidgetState(id, init)
const viaLegacy = useWidgetValueStore().getWidget(graphA, '1', 'seed')
expect(viaLegacy).toBe(getWidgetState(id))
})
})
})

View File

@@ -1,42 +0,0 @@
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { parseWidgetEntityId } from './entityIds'
import type { WidgetEntityId } from './entityIds'
export function getWidgetState(
entityId: WidgetEntityId
): WidgetState | undefined {
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().getWidget(graphId, nodeId, name)
}
export function readWidgetValue(
entityId: WidgetEntityId
): WidgetState['value'] | undefined {
return getWidgetState(entityId)?.value
}
export function writeWidgetValue(
entityId: WidgetEntityId,
value: WidgetState['value']
): boolean {
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().setValue(graphId, nodeId, name, value)
}
type WidgetStateInit = Omit<WidgetState, 'nodeId' | 'name'>
export function ensureWidgetState(
entityId: WidgetEntityId,
init: WidgetStateInit
): WidgetState {
const existing = getWidgetState(entityId)
if (existing) return existing
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
return useWidgetValueStore().registerWidget(graphId, {
...init,
nodeId,
name
})
}