Compare commits

...

9 Commits

Author SHA1 Message Date
bymyself
824ff2b264 test: round-trip serialized payload in subgraph copy roundtrip test
The 'serialized subgraph definition preserves modified widget values'
test previously called subgraphNode.serialize() but discarded the
result, and asserted against subgraph.clone(true).asSerialisable() which
reads live widget state — meaning the assertion could pass even if the
copy/paste serialize path regressed.

Now consumes both serialized payloads via LiteGraph.cloneObject (the
same call _serializeItems uses), then mutates the live widget value
to -1 after capture. The assertion still expects 123 from the snapshot,
so a regression that produced live references rather than snapshots
would fail the test.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010#discussion_r2945448212
2026-05-03 23:03:52 -07:00
bymyself
4b753407e5 test: use shared subgraph helpers in SubgraphNode.serialize.test.ts
Replaces manual SubgraphNode/instanceData construction with the shared
createTestSubgraphNode helper, and switches to subgraphTest (Pinia
auto-setup fixture) so the file matches the conventions of the other
subgraph test files.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010#discussion_r2945026556
2026-05-03 23:00:31 -07:00
Christian Byrne
744b6a8956 fix: wrap configure() widget restoration in hydration transaction (#10201)
Wraps `LGraphNode.configure()`'s widget-value restoration phase in a
hydration transaction (`beginHydration` / `commitHydration`), preventing
derived-state callbacks (e.g. CustomCombo's `updateCombo`) from firing
before all widget values are restored.

```mermaid
flowchart LR
    subgraph core["Core Layer"]
        LGN["LGraphNode.configure()"]
        WVS["widgetValueStore"]
    end

    subgraph extensions["Extension Layer"]
        CW["CustomCombo\n(customWidgets.ts)"]
        PN["PrimitiveNode\n(widgetInputs.ts)"]
    end

    LGN -- "beginHydration /\ncommitHydration" --> WVS
    CW -- "isHydrating() guard" --> WVS
    CW -- "onHydrationComplete()" --> WVS
    PN -- "serialize() override\npreserves widgets_values" --> LGN
```

```mermaid
sequenceDiagram
    participant Caller as Paste / Load
    participant Node as LGraphNode.configure()
    participant Store as widgetValueStore
    participant Widget as Widget Setters
    participant Combo as CustomCombo.updateCombo

    Caller->>Node: configure(serializedData)
    Node->>Store: beginHydration(nodeId)
    activate Store
    Note over Store: hydratingNodes.add(nodeId)

    loop For each widget
        Node->>Widget: widget.value = restored_value
        Widget->>Combo: updateCombo() triggered
        Combo->>Store: isHydrating(nodeId)?
        Store-->>Combo: true - early return
        Note over Combo: Side effect suppressed
    end

    Node->>Node: onConfigure(info)
    Note over Node: Extensions register onHydrationComplete callbacks

    Node->>Store: commitHydration(nodeId)
    Note over Store: hydratingNodes.delete(nodeId)
    Store->>Combo: fire queued callbacks
    Note over Combo: updateCombo() runs once with all values present
    deactivate Store
```

```mermaid
flowchart TB
    subgraph before["Before: Race Condition"]
        direction TB
        B1["configure starts"] --> B2["widget1.value = optionA"]
        B2 --> B3["updateCombo fires immediately"]
        B3 --> B4["values list incomplete,\ncomboWidget.value reset"]
        B4 --> B5["widget2.value = optionB"]
        B5 --> B6["updateCombo fires again"]
        B6 --> B7["Combo has wrong value"]
    end

    subgraph after["After: Hydration Transaction"]
        direction TB
        A1["configure starts"] --> A2["beginHydration nodeId"]
        A2 --> A3["widget1.value = optionA"]
        A3 --> A4["updateCombo skipped"]
        A4 --> A5["widget2.value = optionB"]
        A5 --> A6["updateCombo skipped"]
        A6 --> A7["commitHydration nodeId"]
        A7 --> A8["updateCombo runs once,\nall values present"]
    end
```

- **`LGraphNode.configure()`**: Wraps widget restoration + `onConfigure`
in `beginHydration`/`commitHydration` with `try/finally` for exception
safety
- **`PrimitiveNode.serialize()`**: Adds override to preserve
`widgets_values` when widgets are dynamically disconnected
- **`customWidgets.ts`**: Simplifies CustomCombo's `onConfigure` to use
`onHydrationComplete()` instead of manually managing hydration state
- **3 new tests**: Hydration transaction wrapping, `onHydrationComplete`
callback firing, and exception safety

Stacked on #10010. Implements Design A from the widget state
architecture RFC.
2026-04-18 21:40:31 -07:00
bymyself
ebc5108d90 test: add Playwright e2e tests for SubgraphNode copy-paste
- Copy-paste SubgraphNode preserves promoted widgets
- Pasted SubgraphNode retains proxyWidgets in serialized properties
- Interior widget values survive copy-paste round-trip through loadGraphData

Covers the _serializeItems serialize-not-clone path introduced in this PR.
Requested by DrJKL and CodeRabbit.
2026-04-18 21:37:30 -07:00
bymyself
c5d7119ff2 fix: use barrel import for SubgraphNode, tighten clone test assertion
- Import SubgraphNode from litegraph barrel to avoid circular deps
- Rename test: 'clone with different id' → 'second instance gets its own proxyWidgets'
- Tighten assertion from toBeDefined() to toEqual() with expected value

Addresses CodeRabbit and dante01yoon review feedback.
2026-04-18 21:37:30 -07:00
GitHub Action
40b8d4240c [automated] Apply ESLint and Oxfmt fixes 2026-04-18 21:37:30 -07:00
bymyself
ef13872f63 test: use shared createTestSubgraph helper, fix test name
- Build on createTestSubgraph from __fixtures__/subgraphHelpers instead
  of standalone boilerplate (dante01yoon review feedback)
- Rename misleading test name to match actual assertion (coderabbit)

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010#discussion_r2945026556
https://github.com/Comfy-Org/ComfyUI_frontend/pull/10010#discussion_r2945291401
2026-04-18 21:37:30 -07:00
bymyself
66e75ed1e9 fix: use direct serialization instead of clone in _serializeItems
Per review feedback from @AustinMroz: replace clone().serialize() with
direct serialization for all nodes, clearing links manually. Avoids the
clone->serialize gap where transient nodes lose external state.
2026-04-18 21:37:30 -07:00
bymyself
8eb2d1d078 fix: prevent subgraph state pollution during copy-paste (#9976)
Root cause: _serializeItems used clone().serialize() for SubgraphNode,
creating a transient clone with a new ID. SubgraphNode.serialize() then
queried promotionStore with the wrong ID, producing incorrect
proxyWidgets metadata in the clipboard.

Fix: bypass clone().serialize() for SubgraphNode instances; serialize
the original directly and manually clear links in the serialized data.

Includes 8 unit tests verifying correct serialization behavior and
demonstrating the prior failure mode (ID mismatch during cloning).
2026-04-18 21:37:29 -07:00
10 changed files with 660 additions and 38 deletions

View 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')
}
})
})

View File

@@ -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.

View File

@@ -64,6 +64,7 @@ function onCustomComboCreated(this: LGraphNode) {
).map((w) => `${w.value}`)
)
if (app.configuringGraph || !this.graph) return
if (useWidgetValueStore().isHydrating(this.id)) return
if (values.includes(`${comboWidget.value}`)) return
comboWidget.value = values[0] ?? ''
comboWidget.callback?.(comboWidget.value)
@@ -92,12 +93,17 @@ function onCustomComboCreated(this: LGraphNode) {
},
set(v: string) {
localValue = v
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)
@@ -126,6 +132,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) {

View File

@@ -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()

View File

@@ -3991,9 +3991,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)

View File

@@ -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

View File

@@ -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 {
@@ -897,44 +900,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)
}
/**

View File

@@ -0,0 +1,206 @@
/**
* 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 { describe, expect } from 'vitest'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import { subgraphTest as test } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
createTestSubgraphNode
} 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 + createTestSubgraphNode helpers,
* adding only the widget wiring that the base helpers don'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 (creates promotion binding)
const sgInput = subgraph.addInput('seed', 'INT')
sgInput.connect(nodeInput, interiorNode)
// Shared helper handles SubgraphNode construction (which registers promotions
// via _resolveInputWidget under its own id).
const subgraphNode = createTestSubgraphNode(subgraph, { id: 1 })
rootGraph.add(subgraphNode)
return { rootGraph, subgraph, interiorNode, subgraphNode }
}
describe('SubgraphNode.serialize() state isolation (#9976)', () => {
test('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()
})
test('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].sourceNodeId).toBe(String(interiorNode.id))
expect(promotions[0].sourceWidgetName).toBe('seed')
// Serialize — should write proxyWidgets from promotionStore
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toEqual([
[String(interiorNode.id), 'seed']
])
})
test('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 cloneNode = createTestSubgraphNode(subgraph, { id: 999 })
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']
])
})
test('serialize() preserves modified interior widget values', () => {
const { interiorNode, subgraphNode } = createSubgraphWithWidgetNode()
interiorNode.widgets![0].value = 999
subgraphNode.serialize()
expect(interiorNode.widgets![0].value).toBe(999)
})
test('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)
})
test('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)', () => {
test('serialized subgraph definition preserves modified widget values', () => {
const { subgraph, interiorNode, subgraphNode } =
createSubgraphWithWidgetNode()
interiorNode.widgets![0].value = 123
// Mimic _serializeItems clone path. Both serialized payloads are consumed
// via cloneObject so the assertions below operate on snapshots, not live
// references into the running subgraph.
const serializedInstance = LiteGraph.cloneObject(subgraphNode.serialize())
const serializedDef = LiteGraph.cloneObject(
subgraph.clone(true).asSerialisable()
)
// Mutate the live widget AFTER capture: the snapshot must remain at 123.
// If serialize() ever started writing live references instead of snapshots,
// this assertion would flip to -1.
interiorNode.widgets![0].value = -1
expect(serializedInstance!.id).toBe(subgraphNode.id)
const exportedInterior = serializedDef!.nodes?.find(
(n) => n.id === interiorNode.id
)
expect(exportedInterior?.widgets_values?.[0]).toBe(123)
})
test('multiple instances: serialization order does not affect definition values', () => {
const { rootGraph, subgraph, interiorNode } = createSubgraphWithWidgetNode()
const subgraphNode2 = createTestSubgraphNode(subgraph, {
id: 2,
pos: [300, 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)
})
})

View File

@@ -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()

View File

@@ -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
}
})