mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-10 06:20:03 +00:00
Compare commits
7 Commits
dev/remote
...
fix/subgra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db04381784 | ||
|
|
46bdf07469 | ||
|
|
e001dbb9f1 | ||
|
|
79a232630d | ||
|
|
dd7f4db64f | ||
|
|
6c8b890f54 | ||
|
|
6c0968cef7 |
143
browser_tests/tests/subgraphCopyPaste.spec.ts
Normal file
143
browser_tests/tests/subgraphCopyPaste.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import { TestIds } from '../fixtures/selectors'
|
||||
import {
|
||||
getPromotedWidgetNames,
|
||||
getPromotedWidgets
|
||||
} from '../helpers/promotedWidgets'
|
||||
|
||||
async function getSubgraphNodeIds(comfyPage: ComfyPage): Promise<string[]> {
|
||||
return comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
return graph.nodes
|
||||
.filter(
|
||||
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
)
|
||||
.map((n) => String(n.id))
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Subgraph Copy-Paste', { tag: ['@subgraph', '@widget'] }, () => {
|
||||
test('Copy-paste SubgraphNode preserves promoted widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
const originalPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
||||
expect(originalPromoted).toContain('text')
|
||||
|
||||
// Select the subgraph node
|
||||
await originalNode.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Copy via Ctrl+C, then paste via Ctrl+V
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Should now have 2 subgraph nodes
|
||||
const nodeIds = await getSubgraphNodeIds(comfyPage)
|
||||
expect(nodeIds).toHaveLength(2)
|
||||
|
||||
// Both should have promoted widgets with 'text'
|
||||
for (const nodeId of nodeIds) {
|
||||
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
|
||||
expect(promotedWidgets.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('Copy-paste SubgraphNode preserves proxyWidgets in serialized data', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await originalNode.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The pasted node should have proxyWidgets in its properties
|
||||
const nodeIds = await getSubgraphNodeIds(comfyPage)
|
||||
const pastedId = nodeIds.find((id) => id !== '11')
|
||||
expect(pastedId).toBeDefined()
|
||||
|
||||
const pastedProxyWidgets = await comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.canvas.graph!.getNodeById(id)
|
||||
const pw = node?.properties?.proxyWidgets
|
||||
if (!Array.isArray(pw)) return []
|
||||
return pw as [string, string][]
|
||||
}, pastedId!)
|
||||
|
||||
expect(pastedProxyWidgets.length).toBeGreaterThan(0)
|
||||
|
||||
// The proxyWidgets should reference the 'text' widget
|
||||
const hasTextWidget = pastedProxyWidgets.some(
|
||||
([, widgetName]) => widgetName === 'text'
|
||||
)
|
||||
expect(hasTextWidget).toBe(true)
|
||||
})
|
||||
|
||||
test('Pasted SubgraphNode interior widget values survive round-trip', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const testContent = 'copy-paste-round-trip-test'
|
||||
|
||||
// Set a value on the promoted textarea
|
||||
const textarea = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.domWidgetTextarea
|
||||
)
|
||||
await textarea.first().fill(testContent)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Select and copy the SubgraphNode
|
||||
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
||||
await originalNode.click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Serialize the whole graph and reload to test full round-trip
|
||||
const serialized = await comfyPage.page.evaluate(() => {
|
||||
return window.app!.graph!.serialize()
|
||||
})
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
(workflow) => {
|
||||
return window.app!.loadGraphData(workflow)
|
||||
},
|
||||
serialized as Parameters<typeof comfyPage.page.evaluate>[1]
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Both subgraph nodes should still have promoted widgets
|
||||
const nodeIds = await getSubgraphNodeIds(comfyPage)
|
||||
expect(nodeIds.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
for (const nodeId of nodeIds) {
|
||||
const promoted = await getPromotedWidgetNames(comfyPage, nodeId)
|
||||
expect(promoted).toContain('text')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ Date: 2026-02-22
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
Accepted (Option A)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -42,4 +42,8 @@ Primitives act as a synchronization mechanism — no own state, just a projectio
|
||||
|
||||
## Decision
|
||||
|
||||
Pending. Option A is the most pragmatic first step. Option B can be revisited after Option A ships and stabilizes.
|
||||
Option A. Override `serialize()` on PrimitiveNode to preserve `widgets_values` through copy-paste. This is the lowest-risk fix with no change to connection lifecycle semantics.
|
||||
|
||||
Prerequisite: PR [#10010](https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010) replaced `clone().serialize()` with direct serialization in `_serializeItems`, eliminating the code path that dropped `widgets_values` for widget-less clones. Option A provides the PrimitiveNode-specific fallback for any remaining edge cases.
|
||||
|
||||
Option B can be revisited after Option A ships and stabilizes.
|
||||
|
||||
@@ -64,6 +64,7 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
).map((w) => `${w.value}`)
|
||||
)
|
||||
if (app.configuringGraph) return
|
||||
if (useWidgetValueStore().isHydrating(this.id)) return
|
||||
if (values.includes(`${comboWidget.value}`)) return
|
||||
comboWidget.value = values[0] ?? ''
|
||||
comboWidget.callback?.(comboWidget.value)
|
||||
@@ -88,12 +89,17 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
)?.value
|
||||
},
|
||||
set(v: string) {
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
store.getOrCreateWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
widgetName
|
||||
)
|
||||
if (state) state.value = v
|
||||
widgetName,
|
||||
v
|
||||
).value = v
|
||||
|
||||
if (store.isHydrating(node.id)) return
|
||||
|
||||
updateCombo()
|
||||
if (!node.widgets) return
|
||||
const lastWidget = node.widgets.at(-1)
|
||||
@@ -122,6 +128,13 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
y: 0
|
||||
})
|
||||
addOption(this)
|
||||
|
||||
this.onConfigure = useChainCallback(
|
||||
this.onConfigure,
|
||||
function (this: LGraphNode) {
|
||||
useWidgetValueStore().onHydrationComplete(this.id, updateCombo)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function onCustomIntCreated(this: LGraphNode) {
|
||||
|
||||
@@ -103,6 +103,16 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
}
|
||||
|
||||
override serialize() {
|
||||
const o = super.serialize()
|
||||
// PrimitiveNode creates widgets dynamically on connection. When
|
||||
// disconnected, this.widgets is empty so the base serialize() omits
|
||||
// widgets_values. Fall back to the snapshot saved during configure().
|
||||
if (!o.widgets_values && this.widgets_values)
|
||||
o.widgets_values = [...this.widgets_values]
|
||||
return o
|
||||
}
|
||||
|
||||
override onAfterGraphConfigured() {
|
||||
if (this.outputs[0].links?.length && !this.widgets?.length) {
|
||||
this._onFirstConnection()
|
||||
|
||||
@@ -3902,9 +3902,24 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
// Nodes
|
||||
if (item.clonable === false) continue
|
||||
|
||||
const cloned = item.clone()?.serialize()
|
||||
// Serialize the original node directly instead of clone().serialize().
|
||||
// clone() creates a transient node whose state can diverge from the
|
||||
// original (e.g. SubgraphNode promotionStore keyed by wrong id,
|
||||
// PrimitiveNode losing widgets_values). ID deduplication is already
|
||||
// handled on the deserialize side by _deserializeItems. (#9976)
|
||||
const cloned = LiteGraph.cloneObject(item.serialize())
|
||||
if (!cloned) continue
|
||||
|
||||
// Clear links on the serialized copy (clone() used to do this).
|
||||
if (cloned.inputs) {
|
||||
for (const input of cloned.inputs) input.link = null
|
||||
}
|
||||
if (cloned.outputs) {
|
||||
for (const output of cloned.outputs) {
|
||||
if (output.links) output.links.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
cloned.id = item.id
|
||||
serialisable.nodes.push(cloned)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
NodeInputSlot,
|
||||
NodeOutputSlot
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
import { createMockLGraphNodeWithArrayBoundingRect } from '@/utils/__tests__/litegraphTestUtils'
|
||||
@@ -588,6 +589,76 @@ describe('LGraphNode', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('configure hydration transaction', () => {
|
||||
test('wraps widget-value restoration in hydration transaction', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const hydrationLog: boolean[] = []
|
||||
|
||||
const testNode = new LGraphNode('TestNode')
|
||||
testNode.serialize_widgets = true
|
||||
testNode.addWidget('number', 'a', 0, null)
|
||||
testNode.addWidget('number', 'b', 0, null)
|
||||
|
||||
// Spy on widget value setters to record hydration state
|
||||
const storage = new Map<string, unknown>()
|
||||
for (const widget of testNode.widgets!) {
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get: () => storage.get(widget.name),
|
||||
set(v) {
|
||||
hydrationLog.push(store.isHydrating(testNode.id))
|
||||
storage.set(widget.name, v)
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
|
||||
testNode.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 42,
|
||||
widgets_values: [10, 20]
|
||||
})
|
||||
)
|
||||
|
||||
// Both widget setters ran while hydration was active
|
||||
expect(hydrationLog.every(Boolean)).toBe(true)
|
||||
// Hydration is complete after configure returns
|
||||
expect(store.isHydrating(42)).toBe(false)
|
||||
})
|
||||
|
||||
test('fires onHydrationComplete callbacks after configure', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const calls: string[] = []
|
||||
|
||||
const testNode = new LGraphNode('TestNode')
|
||||
testNode.serialize_widgets = true
|
||||
testNode.addWidget('number', 'a', 0, null)
|
||||
|
||||
testNode.onConfigure = function () {
|
||||
store.onHydrationComplete(this.id, () => calls.push('done'))
|
||||
}
|
||||
|
||||
testNode.configure(
|
||||
getMockISerialisedNode({ id: 99, widgets_values: [42] })
|
||||
)
|
||||
|
||||
expect(calls).toEqual(['done'])
|
||||
})
|
||||
|
||||
test('commitHydration is safe even if onConfigure throws', () => {
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
const testNode = new LGraphNode('TestNode')
|
||||
testNode.onConfigure = () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
testNode.configure(getMockISerialisedNode({ id: 7 }))
|
||||
).toThrow('boom')
|
||||
expect(store.isHydrating(7)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getInputSlotPos', () => {
|
||||
let inputSlot: INodeInputSlot
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import type { ColorAdjustOptions } from '@/utils/colorUtil'
|
||||
import {
|
||||
@@ -891,44 +894,54 @@ export class LGraphNode
|
||||
// SubgraphNode callback.
|
||||
this._internalConfigureAfterSlots?.()
|
||||
|
||||
if (this.widgets) {
|
||||
for (const w of this.widgets) {
|
||||
if (!w) continue
|
||||
// Hydration transaction: suppress derived-state callbacks (e.g.
|
||||
// CustomCombo's updateCombo) until all widget values are restored.
|
||||
// onConfigure handlers may commit early (CustomCombo does); the
|
||||
// final commitHydration is idempotent in that case.
|
||||
const store = getActivePinia() ? useWidgetValueStore() : null
|
||||
store?.beginHydration(this.id)
|
||||
try {
|
||||
if (this.widgets) {
|
||||
for (const w of this.widgets) {
|
||||
if (!w) continue
|
||||
|
||||
const input = this.inputs.find((i) => i.widget?.name === w.name)
|
||||
if (input?.label) w.label = input.label
|
||||
const input = this.inputs.find((i) => i.widget?.name === w.name)
|
||||
if (input?.label) w.label = input.label
|
||||
|
||||
if (
|
||||
w.options?.property &&
|
||||
this.properties[w.options.property] != undefined
|
||||
)
|
||||
w.value = JSON.parse(
|
||||
JSON.stringify(this.properties[w.options.property])
|
||||
if (
|
||||
w.options?.property &&
|
||||
this.properties[w.options.property] != undefined
|
||||
)
|
||||
}
|
||||
w.value = JSON.parse(
|
||||
JSON.stringify(this.properties[w.options.property])
|
||||
)
|
||||
}
|
||||
|
||||
if (info.widgets_values) {
|
||||
let i = 0
|
||||
for (const widget of this.widgets ?? []) {
|
||||
if (widget.serialize === false) continue
|
||||
if (i >= info.widgets_values.length) break
|
||||
widget.value = info.widgets_values[i++]
|
||||
if (info.widgets_values) {
|
||||
let i = 0
|
||||
for (const widget of this.widgets ?? []) {
|
||||
if (widget.serialize === false) continue
|
||||
if (i >= info.widgets_values.length) break
|
||||
widget.value = info.widgets_values[i++]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the state of this.resizable.
|
||||
if (this.pinned) this.resizable = false
|
||||
|
||||
if (this.widgets_up) {
|
||||
console.warn(
|
||||
`[LiteGraph] Node type "${this.type}" uses deprecated property "widgets_up". ` +
|
||||
'This property is unsupported and will be removed. ' +
|
||||
'Use "widgets_start_y" or a custom arrange() override instead.'
|
||||
)
|
||||
}
|
||||
|
||||
this.onConfigure?.(info)
|
||||
} finally {
|
||||
store?.commitHydration(this.id)
|
||||
}
|
||||
|
||||
// Sync the state of this.resizable.
|
||||
if (this.pinned) this.resizable = false
|
||||
|
||||
if (this.widgets_up) {
|
||||
console.warn(
|
||||
`[LiteGraph] Node type "${this.type}" uses deprecated property "widgets_up". ` +
|
||||
'This property is unsupported and will be removed. ' +
|
||||
'Use "widgets_start_y" or a custom arrange() override instead.'
|
||||
)
|
||||
}
|
||||
|
||||
this.onConfigure?.(info)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
226
src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts
Normal file
226
src/lib/litegraph/src/subgraph/SubgraphNode.serialize.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Tests for SubgraphNode serialization state isolation.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. serialize() correctly captures instance-scoped promotion metadata
|
||||
* 2. Direct serialization (without clone()) preserves correct state — the
|
||||
* _serializeItems path uses item.serialize() for all nodes, avoiding the
|
||||
* clone→serialize gap where transient nodes lose external state
|
||||
* 3. Subgraph definition serialization preserves modified widget values
|
||||
*
|
||||
* @see https://github.com/Comfy-Org/ComfyUI_frontend/issues/9976
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
|
||||
import type { Subgraph, LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
|
||||
import { usePromotionStore } from '@/stores/promotionStore'
|
||||
|
||||
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
|
||||
|
||||
/**
|
||||
* Creates a subgraph with a single interior node that has a widget,
|
||||
* wired through a subgraph input. This creates the promotion binding
|
||||
* that serialize() captures in proxyWidgets.
|
||||
*
|
||||
* Builds on the shared createTestSubgraph helper, adding widget wiring
|
||||
* that the base helper doesn't support.
|
||||
*/
|
||||
function createSubgraphWithWidgetNode(): {
|
||||
rootGraph: LGraph
|
||||
subgraph: Subgraph
|
||||
interiorNode: LGraphNode
|
||||
subgraphNode: SubgraphNode
|
||||
} {
|
||||
const subgraph = createTestSubgraph({ name: 'Test Subgraph' })
|
||||
const rootGraph = subgraph.rootGraph
|
||||
|
||||
// Interior node with a widget
|
||||
const interiorNode = new LGraphNode('TestInterior')
|
||||
interiorNode.serialize_widgets = true
|
||||
const nodeInput = interiorNode.addInput('seed', 'INT')
|
||||
nodeInput.widget = { name: 'seed' }
|
||||
interiorNode.addWidget('number', 'seed', 42, () => {})
|
||||
interiorNode.addOutput('out', 'INT')
|
||||
subgraph.add(interiorNode)
|
||||
|
||||
// Wire subgraph input → interior node widget input
|
||||
const sgInput = subgraph.addInput('seed', 'INT')
|
||||
sgInput.connect(nodeInput, interiorNode)
|
||||
|
||||
const instanceData: ExportedSubgraphInstance = {
|
||||
id: 1,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0,
|
||||
order: 0
|
||||
}
|
||||
const subgraphNode = new SubgraphNode(rootGraph, subgraph, instanceData)
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
return { rootGraph, subgraph, interiorNode, subgraphNode }
|
||||
}
|
||||
|
||||
describe('SubgraphNode.serialize() state isolation (#9976)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('inputs have _widget and _subgraphSlot after construction', () => {
|
||||
const { subgraphNode } = createSubgraphWithWidgetNode()
|
||||
|
||||
expect(subgraphNode.inputs).toHaveLength(1)
|
||||
expect(subgraphNode.inputs[0]._subgraphSlot).toBeDefined()
|
||||
expect(subgraphNode.inputs[0]._widget).toBeDefined()
|
||||
})
|
||||
|
||||
it('serialize() captures proxyWidgets from promotionStore for correct instance', () => {
|
||||
const { rootGraph, interiorNode, subgraphNode } =
|
||||
createSubgraphWithWidgetNode()
|
||||
|
||||
const store = usePromotionStore()
|
||||
|
||||
// The SubgraphNode should have promotions registered (from _setWidget)
|
||||
const promotions = store.getPromotions(rootGraph.id, subgraphNode.id)
|
||||
expect(promotions).toHaveLength(1)
|
||||
expect(promotions[0].interiorNodeId).toBe(String(interiorNode.id))
|
||||
expect(promotions[0].widgetName).toBe('seed')
|
||||
|
||||
// Serialize — should write proxyWidgets from promotionStore
|
||||
const serialized = subgraphNode.serialize()
|
||||
expect(serialized.properties?.proxyWidgets).toEqual([
|
||||
[String(interiorNode.id), 'seed']
|
||||
])
|
||||
})
|
||||
|
||||
it('second instance gets its own proxyWidgets from construction', () => {
|
||||
const { rootGraph, subgraph, interiorNode, subgraphNode } =
|
||||
createSubgraphWithWidgetNode()
|
||||
|
||||
const store = usePromotionStore()
|
||||
|
||||
// Original has promotions
|
||||
const promotions = store.getPromotions(rootGraph.id, subgraphNode.id)
|
||||
expect(promotions).toHaveLength(1)
|
||||
|
||||
// Create a second SubgraphNode with a DIFFERENT id (simulating clone)
|
||||
const cloneInstanceData: ExportedSubgraphInstance = {
|
||||
id: 999,
|
||||
type: subgraph.id,
|
||||
pos: [0, 0],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0,
|
||||
order: 0
|
||||
}
|
||||
const cloneNode = new SubgraphNode(rootGraph, subgraph, cloneInstanceData)
|
||||
rootGraph.add(cloneNode)
|
||||
|
||||
// The clone gets proxyWidgets because _resolveInputWidget ran during
|
||||
// construction, registering promotions under its own id (999).
|
||||
const cloneSerialized = cloneNode.serialize()
|
||||
expect(cloneSerialized.properties?.proxyWidgets).toEqual([
|
||||
[String(interiorNode.id), 'seed']
|
||||
])
|
||||
})
|
||||
|
||||
it('serialize() preserves modified interior widget values', () => {
|
||||
const { interiorNode, subgraphNode } = createSubgraphWithWidgetNode()
|
||||
|
||||
interiorNode.widgets![0].value = 999
|
||||
subgraphNode.serialize()
|
||||
|
||||
expect(interiorNode.widgets![0].value).toBe(999)
|
||||
})
|
||||
|
||||
it('asSerialisable() captures current widget values', () => {
|
||||
const { subgraph, interiorNode } = createSubgraphWithWidgetNode()
|
||||
|
||||
interiorNode.widgets![0].value = 777
|
||||
|
||||
const exported = subgraph.asSerialisable()
|
||||
const serializedNode = exported.nodes?.find((n) => n.id === interiorNode.id)
|
||||
expect(serializedNode?.widgets_values?.[0]).toBe(777)
|
||||
})
|
||||
|
||||
it('direct serialize() preserves proxyWidgets and widget values', () => {
|
||||
const { subgraph, interiorNode, subgraphNode } =
|
||||
createSubgraphWithWidgetNode()
|
||||
|
||||
// Direct serialization captures correct proxyWidgets
|
||||
const originalSerialized = subgraphNode.serialize()
|
||||
expect(originalSerialized.properties?.proxyWidgets).toEqual([
|
||||
[String(interiorNode.id), 'seed']
|
||||
])
|
||||
|
||||
// Modify widget value
|
||||
interiorNode.widgets![0].value = 555
|
||||
|
||||
// Subgraph definition serialization should capture modified value
|
||||
const exported = subgraph.clone(true).asSerialisable()
|
||||
const serializedNode = exported.nodes?.find((n) => n.id === interiorNode.id)
|
||||
expect(serializedNode?.widgets_values?.[0]).toBe(555)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph copy roundtrip preserves state (#9976)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('serialized subgraph definition preserves modified widget values', () => {
|
||||
const { subgraph, interiorNode, subgraphNode } =
|
||||
createSubgraphWithWidgetNode()
|
||||
|
||||
interiorNode.widgets![0].value = 123
|
||||
|
||||
// Full _serializeItems-style flow
|
||||
subgraphNode.serialize()
|
||||
const exported = subgraph.clone(true).asSerialisable()
|
||||
|
||||
const serializedNode = exported.nodes?.find((n) => n.id === interiorNode.id)
|
||||
expect(serializedNode?.widgets_values?.[0]).toBe(123)
|
||||
})
|
||||
|
||||
it('multiple instances: serialization order does not affect definition values', () => {
|
||||
const { rootGraph, subgraph, interiorNode } = createSubgraphWithWidgetNode()
|
||||
|
||||
const subgraphNode2 = new SubgraphNode(rootGraph, subgraph, {
|
||||
id: 2,
|
||||
type: subgraph.id,
|
||||
pos: [300, 0],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {},
|
||||
flags: {},
|
||||
mode: 0,
|
||||
order: 0
|
||||
})
|
||||
rootGraph.add(subgraphNode2)
|
||||
|
||||
interiorNode.widgets![0].value = 888
|
||||
|
||||
// Serialize both instances
|
||||
const firstNode = rootGraph.nodes.find(
|
||||
(n): n is SubgraphNode => n instanceof SubgraphNode && n.id === 1
|
||||
)!
|
||||
firstNode.serialize()
|
||||
subgraphNode2.serialize()
|
||||
|
||||
const exported = subgraph.clone(true).asSerialisable()
|
||||
const serializedNode = exported.nodes?.find((n) => n.id === interiorNode.id)
|
||||
expect(serializedNode?.widgets_values?.[0]).toBe(888)
|
||||
})
|
||||
})
|
||||
@@ -926,6 +926,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override _internalConfigureAfterSlots() {
|
||||
this._rebindInputSubgraphSlots()
|
||||
|
||||
// Prune inputs that don't map to any subgraph slot definition.
|
||||
// This prevents stale/duplicate serialized inputs from persisting (#9977).
|
||||
this.inputs = this.inputs.filter((input) => input._subgraphSlot)
|
||||
|
||||
// Ensure proxyWidgets is initialized so it serializes
|
||||
this.properties.proxyWidgets ??= []
|
||||
|
||||
|
||||
@@ -155,6 +155,95 @@ describe('useWidgetValueStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getOrCreateWidget', () => {
|
||||
it('creates a new entry when widget does not exist', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.getOrCreateWidget(graphA, 'node-1', 'option1', 'foo')
|
||||
|
||||
expect(state.nodeId).toBe('node-1')
|
||||
expect(state.name).toBe('option1')
|
||||
expect(state.value).toBe('foo')
|
||||
})
|
||||
|
||||
it('returns existing entry without overwriting value', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'option1', 'string', 'bar'))
|
||||
|
||||
const state = store.getOrCreateWidget(
|
||||
graphA,
|
||||
'node-1',
|
||||
'option1',
|
||||
'should-not-overwrite'
|
||||
)
|
||||
expect(state.value).toBe('bar')
|
||||
})
|
||||
|
||||
it('is idempotent — repeated calls return same reactive entry', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const first = store.getOrCreateWidget(graphA, 'node-1', 'w', 'a')
|
||||
const second = store.getOrCreateWidget(graphA, 'node-1', 'w', 'b')
|
||||
|
||||
expect(first).toBe(second)
|
||||
expect(first.value).toBe('a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hydration transactions', () => {
|
||||
it('beginHydration / isHydrating / commitHydration lifecycle', () => {
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
expect(store.isHydrating('node-1')).toBe(false)
|
||||
|
||||
store.beginHydration('node-1')
|
||||
expect(store.isHydrating('node-1')).toBe(true)
|
||||
expect(store.isHydrating('node-2')).toBe(false)
|
||||
|
||||
store.commitHydration('node-1')
|
||||
expect(store.isHydrating('node-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('commitHydration fires registered callbacks', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const calls: string[] = []
|
||||
|
||||
store.beginHydration('node-1')
|
||||
store.onHydrationComplete('node-1', () => calls.push('a'))
|
||||
store.onHydrationComplete('node-1', () => calls.push('b'))
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
|
||||
store.commitHydration('node-1')
|
||||
expect(calls).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('onHydrationComplete fires immediately when not hydrating', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const calls: string[] = []
|
||||
|
||||
store.onHydrationComplete('node-1', () => calls.push('immediate'))
|
||||
expect(calls).toEqual(['immediate'])
|
||||
})
|
||||
|
||||
it('commitHydration is safe to call when not hydrating', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(() => store.commitHydration('node-1')).not.toThrow()
|
||||
})
|
||||
|
||||
it('hydration is node-scoped — independent per node', () => {
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
store.beginHydration('node-1')
|
||||
store.beginHydration('node-2')
|
||||
|
||||
store.commitHydration('node-1')
|
||||
expect(store.isHydrating('node-1')).toBe(false)
|
||||
expect(store.isHydrating('node-2')).toBe(true)
|
||||
|
||||
store.commitHydration('node-2')
|
||||
expect(store.isHydrating('node-2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates widget states by graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
|
||||
@@ -34,8 +34,12 @@ export interface WidgetState<
|
||||
nodeId: NodeId
|
||||
}
|
||||
|
||||
type HydrationCallback = () => void
|
||||
|
||||
export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
|
||||
const hydratingNodes = new Set<NodeId>()
|
||||
const hydrationCallbacks = new Map<NodeId, HydrationCallback[]>()
|
||||
|
||||
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
|
||||
const widgetStates = graphWidgetStates.value.get(graphId)
|
||||
@@ -57,6 +61,8 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(state.nodeId, state.name)
|
||||
widgetStates.set(key, state)
|
||||
// Return the reactive proxy from the map (not the raw input) so that
|
||||
// callers who hold a reference see Vue-tracked mutations.
|
||||
return widgetStates.get(key) as WidgetState<TValue>
|
||||
}
|
||||
|
||||
@@ -76,6 +82,53 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
}
|
||||
|
||||
function getOrCreateWidget(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
defaultValue?: unknown
|
||||
): WidgetState {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(nodeId, widgetName)
|
||||
const existing = widgetStates.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const state: WidgetState = {
|
||||
nodeId,
|
||||
name: widgetName,
|
||||
type: 'string',
|
||||
value: defaultValue,
|
||||
options: {}
|
||||
}
|
||||
widgetStates.set(key, state)
|
||||
return widgetStates.get(key)!
|
||||
}
|
||||
|
||||
function beginHydration(nodeId: NodeId): void {
|
||||
hydratingNodes.add(nodeId)
|
||||
}
|
||||
|
||||
function commitHydration(nodeId: NodeId): void {
|
||||
hydratingNodes.delete(nodeId)
|
||||
const callbacks = hydrationCallbacks.get(nodeId)
|
||||
if (!callbacks) return
|
||||
|
||||
hydrationCallbacks.delete(nodeId)
|
||||
for (const cb of callbacks) cb()
|
||||
}
|
||||
|
||||
function isHydrating(nodeId: NodeId): boolean {
|
||||
return hydratingNodes.has(nodeId)
|
||||
}
|
||||
|
||||
function onHydrationComplete(nodeId: NodeId, callback: HydrationCallback) {
|
||||
if (!hydratingNodes.has(nodeId)) return callback()
|
||||
|
||||
const existing = hydrationCallbacks.get(nodeId) ?? []
|
||||
if (!existing.includes(callback)) existing.push(callback)
|
||||
hydrationCallbacks.set(nodeId, existing)
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphWidgetStates.value.delete(graphId)
|
||||
}
|
||||
@@ -83,7 +136,12 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
return {
|
||||
registerWidget,
|
||||
getWidget,
|
||||
getOrCreateWidget,
|
||||
getNodeWidgets,
|
||||
beginHydration,
|
||||
commitHydration,
|
||||
isHydrating,
|
||||
onHydrationComplete,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user