Compare commits

...

7 Commits

Author SHA1 Message Date
Christian Byrne
db04381784 fix: wrap configure() widget restoration in hydration transaction (#10201)
## Summary

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.

### Architecture

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

### Hydration Sequence

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

### Before / After

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

### Changes

- **`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

### Part of

Stacked on #10010. Implements Design A from the widget state
architecture RFC.
2026-03-25 21:16:32 -07:00
bymyself
46bdf07469 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-03-17 11:05:11 -07:00
bymyself
e001dbb9f1 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-03-17 11:04:37 -07:00
GitHub Action
79a232630d [automated] Apply ESLint and Oxfmt fixes 2026-03-17 09:21:27 +00:00
bymyself
dd7f4db64f 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-03-17 09:18:02 +00:00
bymyself
6c8b890f54 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-03-17 08:52:43 +00:00
bymyself
6c0968cef7 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-03-16 06:00:44 +00:00
11 changed files with 684 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) 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) {

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

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

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 {
@@ -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)
}
/**

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

View File

@@ -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 ??= []

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