Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 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
599 changes: 599 additions & 0 deletions browser_tests/assets/subgraphs/subgraph-nested-duplicate-ids.json

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions browser_tests/tests/subgraph-duplicate-ids.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { expect } from '@playwright/test'

import { comfyPageFixture as test } from '../fixtures/ComfyPage'

test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'

test('All node IDs are globally unique after loading', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)

const result = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
// TODO: Extract allGraphs accessor (root + subgraphs) into LGraph
// TODO: Extract allNodeIds accessor into LGraph
const allGraphs = [graph, ...graph.subgraphs.values()]
const allIds = allGraphs
.flatMap((g) => g._nodes)
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')

return { allIds, uniqueCount: new Set(allIds).size }
})

expect(result.uniqueCount).toBe(result.allIds.length)
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
})

test('Root graph node IDs are preserved as canonical', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)

const rootIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.map((n) => n.id)
.filter((id): id is number => typeof id === 'number')
.sort((a, b) => a - b)
})

expect(rootIds).toEqual([1, 2, 5])
})

test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)

const invalidLinks = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const labeledGraphs: [string, typeof graph][] = [
['root', graph],
...[...graph.subgraphs.entries()].map(
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
)
]

const isNonNegative = (id: number | string) =>
typeof id === 'number' && id >= 0

return labeledGraphs.flatMap(([label, g]) =>
[...g._links.values()].flatMap((link) =>
[
isNonNegative(link.origin_id) &&
!g._nodes_by_id[link.origin_id] &&
`${label}: origin_id ${link.origin_id} not found`,
isNonNegative(link.target_id) &&
!g._nodes_by_id[link.target_id] &&
`${label}: target_id ${link.target_id} not found`
].filter(Boolean)
)
)
})

expect(invalidLinks).toEqual([])
})

test('Subgraph navigation works after ID remapping', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)

const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
await subgraphNode.navigateIntoSubgraph()

const isInSubgraph = () =>
comfyPage.page.evaluate(
() => window.app!.canvas.graph?.isRootGraph === false
)

expect(await isInSubgraph()).toBe(true)

await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()

expect(await isInSubgraph()).toBe(false)
})
})
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/TabNormalInputs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,7 @@ const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
.map((node) => {
const { widgets = [] } = node
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
)
.filter((w) => !(w.options?.canvasOnly || w.hidden) && w.advanced)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }
})
Expand Down
5 changes: 1 addition & 4 deletions src/components/rightSidePanel/parameters/WidgetItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,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
4 changes: 2 additions & 2 deletions src/components/rightSidePanel/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,8 @@ export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
(w) =>
!(
w.options?.canvasOnly ||
w.options?.hidden ||
(w.options?.advanced && !includesAdvanced.value)
w.hidden ||
(w.advanced && !includesAdvanced.value)
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.

note: I think this also will break for old/legacy widgets that are not instanceof BaseWidget

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed hidden/advanced, keeping those half in options for now.

)
)
.map((widget) => ({ node, widget }))
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)
})
})
Loading
Loading