Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
80a5108
feat: add WidgetValueStore for centralized widget values
DrJKL Feb 3, 2026
fcd83d8
feat: integrate BaseWidget with WidgetValueStore
DrJKL Feb 3, 2026
85c6bd1
feat: LGraphNode.addCustomWidget wires widget to node ID
DrJKL Feb 3, 2026
61403ea
test: add Pinia setup to proxyWidget tests for WidgetValueStore
DrJKL Feb 3, 2026
b1e066e
feat: integrate WidgetValueStore with BaseWidget for centralized reac…
DrJKL Feb 4, 2026
3988c4f
feat: expand widgetValueStore with full widget state management
DrJKL Feb 4, 2026
36387fa
feat: integrate BaseWidget metadata properties with widgetValueStore
DrJKL Feb 4, 2026
f9db91d
feat: implement automatic widget registration in setNodeId()
DrJKL Feb 4, 2026
4a491c5
feat: add useWidget composable and WidgetItemByKey component
DrJKL Feb 4, 2026
bb13bec
refactor(litegraph): add widget filtering helper methods
DrJKL Feb 4, 2026
ee8500d
refactor: simplify SafeWidgetData by leveraging widgetValueStore
DrJKL Feb 4, 2026
616b2d6
Knip fix
DrJKL Feb 4, 2026
6e3d6f6
Remove redundant state from Widget store.
DrJKL Feb 4, 2026
4ac436c
fix: sync DOM widget values with WidgetValueStore
DrJKL Feb 4, 2026
687c77f
refactor: derive WidgetState from IBaseWidget via Pick
DrJKL Feb 4, 2026
a9b0def
refactor: remove redundant setters from widgetValueStore
DrJKL Feb 4, 2026
ea26a16
refactor: consolidate BaseWidget fields with WidgetState
DrJKL Feb 4, 2026
627ca0b
YAGNI: useWidget
DrJKL Feb 4, 2026
8473ecd
Re-bind the state to the reactive object proxy.
DrJKL Feb 4, 2026
6f06ce7
Keep the widget array itself reactive, Add proxy support (to be clean…
DrJKL Feb 5, 2026
644295c
fix: clean up widget state when nodes are removed
DrJKL Feb 5, 2026
c89ec31
refactor: use store-backed widget.hidden/advanced instead of legacy p…
DrJKL Feb 5, 2026
ffac1c1
test: replace as any with proper BaseWidget type assertions
DrJKL Feb 5, 2026
2288c25
fix: use correct widget name for proxy widget matching
DrJKL Feb 5, 2026
88eb864
refactor: remove unused widgetValueStore methods
DrJKL Feb 5, 2026
395940c
Enforce calling the setter to keep the store synced as the source of …
DrJKL Feb 5, 2026
e26261c
[automated] Update test expectations
invalid-email-address Feb 5, 2026
e1206d3
Merge branch 'main' into drjkl/widget-store
DrJKL Feb 5, 2026
bd39cad
Mark the nodeId as optional during setup of the widget.
DrJKL Feb 6, 2026
7bc3936
merge main
DrJKL Feb 6, 2026
5d1e9f6
feat: share LGraphState between root graph and subgraphs
DrJKL Feb 6, 2026
09b347a
[automated] Apply ESLint and Oxfmt fixes
actions-user Feb 6, 2026
3e5ed3c
feat: add ensureGlobalIdUniqueness for cross-graph ID deduplication
DrJKL Feb 6, 2026
e6e3164
[automated] Update test expectations
invalid-email-address Feb 6, 2026
b274c6f
fix: use getNodeRefsByType for subgraph nodes in E2E tests
DrJKL Feb 6, 2026
2478af2
fix: remap floating link node IDs in ensureGlobalIdUniqueness
DrJKL Feb 6, 2026
c116f3c
fix: Widgets need different names to be able to have different hidden…
DrJKL Feb 6, 2026
ac8d762
Update uniqueness conversion.
DrJKL Feb 6, 2026
c9f249e
test: add Playwright tests for subgraph duplicate ID remapping
DrJKL Feb 6, 2026
ea2fe2b
[automated] Apply ESLint and Oxfmt fixes
actions-user Feb 6, 2026
215bc8c
refactor: clean up subgraph duplicate ID spec
DrJKL Feb 6, 2026
b0e03c9
Merge remote-tracking branch 'origin/main' into drjkl/widget-store
DrJKL Feb 6, 2026
2a9d1d1
Merge remote-tracking branch 'origin/main' into drjkl/widget-store
DrJKL Feb 7, 2026
117f6a0
[automated] Update test expectations
invalid-email-address Feb 7, 2026
18e75ca
fix: correct widget advanced/read_only option handling
DrJKL Feb 8, 2026
b477d00
Merge branch 'main' into drjkl/widget-store
DrJKL Feb 8, 2026
82681db
refactor: extract NodeBindable interface and isNodeBindable type guard
DrJKL Feb 9, 2026
269ef6c
fix: avoid ID collisions in ensureGlobalIdUniqueness
DrJKL Feb 9, 2026
f2a0395
Merge branch 'main' into drjkl/widget-store
DrJKL Feb 9, 2026
66e75c2
Merge branch 'main' into drjkl/widget-store
DrJKL Feb 9, 2026
52107d0
feat: gate ensureGlobalIdUniqueness behind experimental setting
DrJKL Feb 10, 2026
d695060
merge: resolve conflict with origin/main, keep 1.40.0 versionAdded
DrJKL Feb 10, 2026
67a0ff0
fix: remove hidden and advanced from widgetValueStore
DrJKL Feb 10, 2026
2cd525a
fix: restore hidden/advanced reads from widget.options to match main
DrJKL Feb 10, 2026
790f74a
Remove option to not dedupe node IDs since it's required for WidgetVa…
DrJKL Feb 10, 2026
0a611ae
Merge branch 'main' into drjkl/widget-store
DrJKL Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions browser_tests/tests/subgraph-duplicate-ids.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,6 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'

test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Graph.DeduplicateSubgraphNodeIds',
true
)
})

test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
Expand Down
10 changes: 8 additions & 2 deletions browser_tests/tests/subgraph.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()

const initialCount = await getSubgraphSlotCount(comfyPage, 'inputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)

await comfyPage.subgraph.connectFromInput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
Expand All @@ -77,7 +80,10 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
await subgraphNode.navigateIntoSubgraph()

const initialCount = await getSubgraphSlotCount(comfyPage, 'outputs')
const vaeEncodeNode = await comfyPage.nodeOps.getNodeRefById('2')
const [vaeEncodeNode] = await comfyPage.nodeOps.getNodeRefsByType(
'VAEEncode',
true
)

await comfyPage.subgraph.connectToOutput(vaeEncodeNode, 0)
await comfyPage.nextFrame()
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 1 addition & 4 deletions src/components/rightSidePanel/parameters/WidgetItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,7 @@ const favoriteNode = computed(() =>
)

const widgetValue = computed({
get: () => {
widget.vueTrack?.()
return widget.value
},
get: () => widget.value,
set: (newValue: string | number | boolean | object) => {
emit('update:widgetValue', newValue)
}
Expand Down
79 changes: 53 additions & 26 deletions src/composables/graph/useGraphNodeManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,76 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it, vi } from 'vitest'
import { nextTick, watch } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'

import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useWidgetValueStore } from '@/stores/widgetValueStore'

setActivePinia(createTestingPinia())
describe('Node Reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})

function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)
function createTestGraph() {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addInput('input', 'INT')
node.addWidget('number', 'testnum', 2, () => undefined, {})
graph.add(node)

const { vueNodeData } = useGraphNodeManager(graph)
const onReactivityUpdate = vi.fn()
watch(vueNodeData, onReactivityUpdate)
const { vueNodeData } = useGraphNodeManager(graph)

return [node, graph, onReactivityUpdate] as const
}
return { node, graph, vueNodeData }
}

describe('Node Reactivity', () => {
it('should trigger on callback', async () => {
const [node, , onReactivityUpdate] = createTestGraph()
it('widget values are reactive through the store', async () => {
const { node } = createTestGraph()
const store = useWidgetValueStore()
const widget = node.widgets![0]

// Verify widget is a BaseWidget with correct value and node assignment
expect(widget).toBeInstanceOf(BaseWidget)
expect(widget.value).toBe(2)
expect((widget as BaseWidget).node.id).toBe(node.id)

// Initial value should be in store after setNodeId was called
expect(store.getWidget(node.id, 'testnum')?.value).toBe(2)

node.widgets![0].callback!(2)
const onValueChange = vi.fn()
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)

widget.value = 42
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)

expect(widgetValue.value).toBe(42)
expect(onValueChange).toHaveBeenCalledTimes(1)
})

it('should remain reactive after a connection is made', async () => {
const [node, graph, onReactivityUpdate] = createTestGraph()
it('widget values remain reactive after a connection is made', async () => {
const { node, graph } = createTestGraph()
const store = useWidgetValueStore()
const onValueChange = vi.fn()

graph.trigger('node:slot-links:changed', {
nodeId: '1',
nodeId: String(node.id),
slotType: NodeSlotType.INPUT
})
await nextTick()
onReactivityUpdate.mockClear()

node.widgets![0].callback!(2)
const widgetValue = computed(
() => store.getWidget(node.id, 'testnum')?.value
)
watch(widgetValue, onValueChange)

node.widgets![0].value = 99
await nextTick()
expect(onReactivityUpdate).toHaveBeenCalledTimes(1)

expect(onValueChange).toHaveBeenCalledTimes(1)
expect(widgetValue.value).toBe(99)
})
})
101 changes: 52 additions & 49 deletions src/composables/graph/useGraphNodeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,15 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import { customRef, reactive, shallowReactive } from 'vue'
import { reactive, shallowReactive } from 'vue'

import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { NodeId } from '@/renderer/core/layout/types'
Expand All @@ -41,19 +38,37 @@ export interface WidgetSlotMetadata {
linked: boolean
}

/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
nodeId?: NodeId
name: string
type: string
value: WidgetValue
borderStyle?: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
label?: string
/** Node type (for subgraph promoted widgets) */
nodeType?: string
options?: IWidgetOptions
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
}

Expand Down Expand Up @@ -95,23 +110,6 @@ export interface GraphNodeManager {
cleanup(): void
}

function widgetWithVueTrack(
widget: IBaseWidget
): asserts widget is IBaseWidget & { vueTrack: () => void } {
if (widget.vueTrack) return

customRef((track, trigger) => {
widget.callback = useChainCallback(widget.callback, trigger)
widget.vueTrack = track
return { get() {}, set() {} }
})
}
function useReactiveWidgetValue(widget: IBaseWidget) {
widgetWithVueTrack(widget)
widget.vueTrack()
return widget.value
}

function getControlWidget(widget: IBaseWidget): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find(
(w) => w.name == 'control_after_generate'
Expand All @@ -133,26 +131,18 @@ function getNodeType(node: LGraphNode, widget: IBaseWidget) {
* Shared widget enhancements used by both safeWidgetMapper and Right Side Panel
*/
interface SharedWidgetEnhancements {
/** Reactive widget value that updates when the widget changes */
value: WidgetValue
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Input specification from node definition */
spec?: InputSpec
/** Node type (for subgraph promoted widgets) */
nodeType?: string
/** Border style for promoted/advanced widgets */
borderStyle?: string
/** Widget label */
label?: string
/** Widget options */
options?: IWidgetOptions
}

/**
* Extracts common widget enhancements shared across different rendering contexts.
* This function centralizes the logic for extracting metadata and reactive values
* from widgets, ensuring consistency between Nodes 2.0 and Right Side Panel.
* This function centralizes the logic for extracting metadata from widgets.
* Note: Value and metadata (label, options, hidden, etc.) are accessed via widgetValueStore.
*/
export function getSharedWidgetEnhancements(
node: LGraphNode,
Expand All @@ -161,17 +151,9 @@ export function getSharedWidgetEnhancements(
const nodeDefStore = useNodeDefStore()

return {
value: useReactiveWidgetValue(widget),
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name),
nodeType: getNodeType(node, widget),
borderStyle: widget.promoted
? 'ring ring-component-node-widget-promoted'
: widget.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
label: widget.label,
options: widget.options as IWidgetOptions
nodeType: getNodeType(node, widget)
}
}

Expand Down Expand Up @@ -212,7 +194,7 @@ function safeWidgetMapper(
): (widget: IBaseWidget) => SafeWidgetData {
return function (widget) {
try {
// Get shared enhancements used by both Nodes 2.0 and Right Side Panel
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo = slotMetadata.get(widget.name)

Expand All @@ -228,20 +210,41 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}

// Extract only render-critical options (canvasOnly, advanced, read_only)
Copy link
Contributor

@christian-byrne christian-byrne Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/question: Ah, didn't see this when I made previous comment. Wonder if it's the best solution for wiring inputSpec onto the widgets.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost certainly not. we'd probably want to compose this with the nodeDef store. Part of that will also be eliminating ProxyWidgets so that we can avoid having to follow the subgraph chain through to the concrete widget.

const options = widget.options
? {
canvasOnly: widget.options.canvasOnly,
advanced: widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
: undefined
const subgraphId = node.isSubgraphNode() && node.subgraph.id

const localId = isProxyWidget(widget)
? widget._overlay?.nodeId
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const name = isProxyWidget(widget)
? widget._overlay.widgetName
: widget.name

return {
name: widget.name,
nodeId,
name,
type: widget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget),
options,
slotMetadata: slotInfo
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined
type: widget.type || 'text'
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/core/graph/subgraph/proxyWidget.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, expect, test, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'

import { registerProxyWidgets } from '@/core/graph/subgraph/proxyWidget'
import { promoteWidget } from '@/core/graph/subgraph/proxyWidgetUtils'
Expand Down Expand Up @@ -43,6 +45,10 @@ function setupSubgraph(
}

describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})

test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
Expand Down
9 changes: 6 additions & 3 deletions src/core/graph/subgraph/proxyWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,9 +115,11 @@ const onConfigure = function (
if (isActiveGraph && w instanceof DOMWidgetImpl) setWidget(w)
return [w]
})
this.widgets = this.widgets.filter(
(w) => !isProxyWidget(w) && !parsed.some(([, name]) => w.name === name)
)
this.widgets = this.widgets.filter((w) => {
if (isProxyWidget(w)) return false
const widgetName = w.name
return !parsed.some(([, name]) => widgetName === name)
})
this.widgets.push(...newWidgets)

canvasStore.canvas?.setDirty(true, true)
Expand Down Expand Up @@ -152,6 +154,7 @@ function newProxyWidget(
computedHeight: undefined,
isProxyWidget: true,
last_y: undefined,
label: name,
name,
node: subgraphNode,
onRemove: undefined,
Expand Down
Loading