mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 06:35:10 +00:00
[backport cloud/1.43] revert: roll back #10849 + #11697 (per-instance promoted widget values) (#11790) (#12098)
*PR Created by the Glary-Bot Agent* --- ## Summary Backport of #11790 onto `cloud/1.43`. Reverts #10849 (per-instance promoted widget value storage) and its companion test-pinning PR #11697. **Why this is needed on `cloud/1.43`:** Glary-Bot's consolidated backport (#11926) cherry-picked #10849 into `cloud/1.43` on May 5 — four days *after* the same change was reverted on `main` in #11790. The audit didn't surface the upstream revert. As a result, `cloud/1.43` (1.43.16) currently ships the buggy #10849 implementation while `main` / nightly (1.45.1) doesn't. This is the cause of the Hub `rob_realistic_2k_images_quick_variations` model/vae/clip mixup regression reported in `#bug-dump`; the same template renders correctly on nightly. After this lands, `cloud/1.43` and `main` will be on the same code path for promoted-widget serialization. The replacement fix (#11811 with `_pendingWidgetsValuesReplay`) will land separately on `main` and can be backported afterward. ## Changes Mirrors the upstream revert of #10849 + #11697: - Removes `_instanceWidgetValues` map, `_pendingWidgetsValues` hydration, and the `widgets_values` write path in `SubgraphNode.serialize()` - Removes `sourceSerialize` field on `PromotedWidgetView` - Removes the multi-instance Vitest suite (`SubgraphNode.multiInstance.test.ts`) and the multi-instance E2E test + workflow asset ## Conflict Resolution Two conflicts surfaced (vs the original revert, which conflict-resolved against #11579 on `main`): 1. **`browser_tests/tests/subgraph/subgraphSerialization.spec.ts`** — `cloud/1.43` does not have the #11579 expanded test coverage (`Duplicate ID Remapping`, `Legacy Prefixed proxyWidget Normalization`) that the original revert preserved. Took the `cloud/1.43` HEAD structure and removed only the now-unreachable multi-instance test, its helper `getPromotedHostWidgetValues`, and the unused `ComfyPage` import. 2. **`src/lib/litegraph/src/subgraph/SubgraphNode.multiInstance.test.ts`** — `modify/delete` (file was present on `cloud/1.43`'s #10849 backport, deleted by the revert). Resolved by removing the file, matching the revert. ## Verification **Static analysis & tests:** - `pnpm typecheck` — clean - `pnpm typecheck:browser` — clean - `pnpm exec eslint` on all changed files — clean - `pnpm test:unit -- --run subgraph` — 506 tests passing across 34 suites (`SubgraphNode.test.ts`, `promotedWidgetView.test.ts`, `subgraphNodePromotion.test.ts`, etc.) **Manual verification (Playwright + local dev server):** - Started ComfyUI backend (`--cpu --port 8188`) and `pnpm dev` frontend; loaded the page successfully. - Loaded `subgraph-with-multiple-promoted-widgets.json` — subgraph node hydrated with 2 promoted widgets. - Set distinct values on each promoted widget (`first-promoted-value`, `second-promoted-value`), serialized via `app.graph.serialize()`, and reloaded via `app.loadGraphData(...)`. Both values round-tripped correctly to the right widget slots — confirming the revert restores the working single-instance path that nightly currently ships. - Hub-fetched template (`rob_realistic_2k_images_quick_variations`) could not be reproduced locally — `comfy.org/workflows/...` API returns 404 to anonymous curl from the sandbox, and the Hub UI flow needs auth wired up. The team has already confirmed this template renders correctly on nightly, which ships this same revert. ## Note on Oracle Review Oracle flagged the multi-instance regression and the cloud/1.43 forward-compat trade-off as "critical." These describe the *intended* behavior of this revert — the team has consciously chosen the legacy clobber path over #10849's broken per-instance code while the replacement (#11811) is finalized on `main`. Both findings are accepted trade-offs, not blockers. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12098-backport-cloud-1-43-revert-roll-back-10849-11697-per-instance-promoted-widget-va-35b6d73d36508109a715fc9d3428a294) by [Unito](https://www.unito.io) Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
@@ -1,284 +0,0 @@
|
||||
{
|
||||
"id": "2f54e2f0-6db4-4bdf-84a8-9c3ea3ec0123",
|
||||
"revision": 0,
|
||||
"last_node_id": 13,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 11,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [120, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Alpha\n"]
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [420, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Beta\n"]
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"type": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"pos": [720, 180],
|
||||
"size": [210, 168],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "clip", "type": "CLIP", "link": null },
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Gamma\n"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "422723e8-4bf6-438c-823f-881ca81acead",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 11,
|
||||
"lastLinkId": 15,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [481.59912109375, 379.13336181640625, 120, 160]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1121.59912109375, 379.13336181640625, 120, 40]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "0f07c10e-5705-4764-9b24-b69606c6dbcc",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"linkIds": [10],
|
||||
"pos": { "0": 581.59912109375, "1": 399.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "214a5060-24dd-4299-ab78-8027dc5b9c59",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"linkIds": [11],
|
||||
"pos": { "0": 581.59912109375, "1": 419.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8ab94c5d-e7df-433c-9177-482a32340552",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [12],
|
||||
"pos": { "0": 581.59912109375, "1": 439.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "8a4cd719-8c67-473b-9b44-ac0582d02641",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [13],
|
||||
"pos": { "0": 581.59912109375, "1": 459.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "a78d6b3a-ad40-4300-b0a5-2cdbdb8dc135",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [14],
|
||||
"pos": { "0": 581.59912109375, "1": 479.13336181640625 }
|
||||
},
|
||||
{
|
||||
"id": "4c7abe0c-902d-49ef-a5b0-cbf02b50b693",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"linkIds": [15],
|
||||
"pos": { "0": 581.59912109375, "1": 499.13336181640625 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [661.59912109375, 314.13336181640625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "text",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": { "name": "text" },
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "CONDITIONING",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [""]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "KSampler",
|
||||
"pos": [674.1234741210938, 570.5839233398438],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 15
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 10,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 10,
|
||||
"target_slot": 1,
|
||||
"type": "STRING"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 1,
|
||||
"target_id": 10,
|
||||
"target_slot": 0,
|
||||
"type": "CLIP"
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 2,
|
||||
"target_id": 11,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 3,
|
||||
"target_id": 11,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 4,
|
||||
"target_id": 11,
|
||||
"target_slot": 2,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 5,
|
||||
"target_id": 11,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { getPromotedWidgets } from '@e2e/helpers/promotedWidgets'
|
||||
|
||||
@@ -9,31 +8,6 @@ const LEGACY_PREFIXED_WORKFLOW =
|
||||
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
|
||||
|
||||
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
const getPromotedHostWidgetValues = async (
|
||||
comfyPage: ComfyPage,
|
||||
nodeIds: string[]
|
||||
) => {
|
||||
return comfyPage.page.evaluate((ids) => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
return ids.map((id) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (
|
||||
!node ||
|
||||
typeof node.isSubgraphNode !== 'function' ||
|
||||
!node.isSubgraphNode()
|
||||
) {
|
||||
return { id, values: [] as unknown[] }
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
values: (node.widgets ?? []).map((widget) => widget.value)
|
||||
}
|
||||
})
|
||||
}, nodeIds)
|
||||
}
|
||||
|
||||
test('Promoted widget remains usable after serialize and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -109,35 +83,5 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
|
||||
await expect(textarea).toBeVisible()
|
||||
await expect(textarea).toBeDisabled()
|
||||
})
|
||||
|
||||
test('Multiple instances of the same subgraph keep distinct promoted widget values after load and reload', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const workflowName =
|
||||
'subgraphs/subgraph-multi-instance-promoted-text-values'
|
||||
const hostNodeIds = ['11', '12', '13']
|
||||
const expectedValues = ['Alpha\n', 'Beta\n', 'Gamma\n']
|
||||
|
||||
await comfyPage.workflow.loadWorkflow(workflowName)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const initialValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(initialValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.serializeAndReload()
|
||||
|
||||
const reloadedValues = await getPromotedHostWidgetValues(
|
||||
comfyPage,
|
||||
hostNodeIds
|
||||
)
|
||||
expect(reloadedValues.map(({ values }) => values[0])).toEqual(
|
||||
expectedValues
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface PromotedWidgetView extends IBaseWidget {
|
||||
* origin.
|
||||
*/
|
||||
readonly disambiguatingSourceNodeId?: string
|
||||
/** Whether the resolved source widget is workflow-persistent. */
|
||||
readonly sourceSerialize: boolean
|
||||
}
|
||||
|
||||
export function isPromotedWidgetView(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { isEqual } from 'es-toolkit'
|
||||
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
@@ -53,43 +50,6 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
|
||||
}
|
||||
|
||||
const designTokenCache = new Map<string, string>()
|
||||
const promotedSourceWriteMetaByGraph = new WeakMap<
|
||||
LGraph,
|
||||
Map<string, PromotedSourceWriteMeta>
|
||||
>()
|
||||
|
||||
interface PromotedSourceWriteMeta {
|
||||
value: IBaseWidget['value']
|
||||
writerInstanceId: string
|
||||
}
|
||||
|
||||
function cloneWidgetValue<TValue extends IBaseWidget['value']>(
|
||||
value: TValue
|
||||
): TValue {
|
||||
return value != null && typeof value === 'object'
|
||||
? (JSON.parse(JSON.stringify(value)) as TValue)
|
||||
: value
|
||||
}
|
||||
|
||||
function getPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string
|
||||
): PromotedSourceWriteMeta | undefined {
|
||||
return promotedSourceWriteMetaByGraph.get(graph)?.get(sourceKey)
|
||||
}
|
||||
|
||||
function setPromotedSourceWriteMeta(
|
||||
graph: LGraph,
|
||||
sourceKey: string,
|
||||
meta: PromotedSourceWriteMeta
|
||||
): void {
|
||||
let metaBySource = promotedSourceWriteMetaByGraph.get(graph)
|
||||
if (!metaBySource) {
|
||||
metaBySource = new Map<string, PromotedSourceWriteMeta>()
|
||||
promotedSourceWriteMetaByGraph.set(graph, metaBySource)
|
||||
}
|
||||
metaBySource.set(sourceKey, meta)
|
||||
}
|
||||
|
||||
export function createPromotedWidgetView(
|
||||
subgraphNode: SubgraphNode,
|
||||
@@ -117,15 +77,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
|
||||
readonly serialize = false
|
||||
|
||||
/**
|
||||
* Whether the resolved source widget is workflow-persistent.
|
||||
* Used by SubgraphNode.serialize to skip preview/audio/video widgets
|
||||
* whose source sets serialize = false.
|
||||
*/
|
||||
get sourceSerialize(): boolean {
|
||||
return this.resolveDeepest()?.widget.serialize !== false
|
||||
}
|
||||
|
||||
last_y?: number
|
||||
computedHeight?: number
|
||||
|
||||
@@ -198,52 +149,13 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return this.resolveDeepest()?.widget.linkedWidgets
|
||||
}
|
||||
|
||||
private get _instanceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
private get _sharedSourceKey(): string {
|
||||
return this.disambiguatingSourceNodeId
|
||||
? `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}:${this.disambiguatingSourceNodeId}`
|
||||
: `${this.subgraphNode.subgraph.id}:${this.sourceNodeId}:${this.sourceWidgetName}`
|
||||
}
|
||||
|
||||
get value(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution-time serialization — returns the per-instance value stored
|
||||
* during configure, falling back to the regular value getter.
|
||||
*
|
||||
* The widget state store is shared across instances (keyed by inner node
|
||||
* ID), so the regular getter returns the last-configured value for all
|
||||
* instances. graphToPrompt already prefers serializeValue over .value,
|
||||
* so this is the hook that makes multi-instance execution correct.
|
||||
*/
|
||||
serializeValue(): IBaseWidget['value'] {
|
||||
return this.getTrackedValue()
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
set value(value: IBaseWidget['value']) {
|
||||
this.captureSiblingFallbackValues()
|
||||
|
||||
// Keep per-instance map in sync for execution (graphToPrompt)
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(value)
|
||||
)
|
||||
setPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey,
|
||||
{
|
||||
value: cloneWidgetValue(value),
|
||||
writerInstanceId: String(this.subgraphNode.id)
|
||||
}
|
||||
)
|
||||
|
||||
const linkedWidgets = this.getLinkedInputWidgets()
|
||||
if (linkedWidgets.length > 0) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
@@ -473,39 +385,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
return resolved
|
||||
}
|
||||
|
||||
private getTrackedValue(): IBaseWidget['value'] {
|
||||
const instanceValue = this.subgraphNode._instanceWidgetValues.get(
|
||||
this._instanceKey
|
||||
)
|
||||
const sharedValue = this.getSharedValue()
|
||||
|
||||
if (instanceValue === undefined) return sharedValue
|
||||
|
||||
const sourceWriteMeta = getPromotedSourceWriteMeta(
|
||||
this.subgraphNode.rootGraph,
|
||||
this._sharedSourceKey
|
||||
)
|
||||
if (
|
||||
sharedValue !== undefined &&
|
||||
sourceWriteMeta &&
|
||||
!isEqual(sharedValue, sourceWriteMeta.value)
|
||||
) {
|
||||
this.subgraphNode._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(sharedValue)
|
||||
)
|
||||
return sharedValue
|
||||
}
|
||||
|
||||
return instanceValue as IBaseWidget['value']
|
||||
}
|
||||
|
||||
private getSharedValue(): IBaseWidget['value'] {
|
||||
const state = this.getWidgetState()
|
||||
if (state && isWidgetValue(state.value)) return state.value
|
||||
return this.resolveAtHost()?.widget.value
|
||||
}
|
||||
|
||||
private getWidgetState() {
|
||||
const linkedState = this.getLinkedInputWidgetStates()[0]
|
||||
if (linkedState) return linkedState
|
||||
@@ -572,30 +451,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
private captureSiblingFallbackValues(): void {
|
||||
const { rootGraph } = this.subgraphNode
|
||||
|
||||
for (const node of rootGraph.nodes) {
|
||||
if (node === this.subgraphNode || !node.isSubgraphNode()) continue
|
||||
if (node.subgraph.id !== this.subgraphNode.subgraph.id) continue
|
||||
if (node._instanceWidgetValues.has(this._instanceKey)) continue
|
||||
|
||||
const siblingView = node.widgets.find(
|
||||
(widget): widget is IPromotedWidgetView =>
|
||||
isPromotedWidgetView(widget) &&
|
||||
widget.sourceNodeId === this.sourceNodeId &&
|
||||
widget.sourceWidgetName === this.sourceWidgetName &&
|
||||
widget.disambiguatingSourceNodeId === this.disambiguatingSourceNodeId
|
||||
)
|
||||
if (!siblingView) continue
|
||||
|
||||
node._instanceWidgetValues.set(
|
||||
this._instanceKey,
|
||||
cloneWidgetValue(siblingView.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private getProjectedWidget(resolved: {
|
||||
node: LGraphNode
|
||||
widget: IBaseWidget
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Subgraph proxyWidgets', () => {
|
||||
expect(subgraphNode.widgets).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('serialize stores widgets_values for promoted views', () => {
|
||||
test('serialize does not produce widgets_values for promoted views', () => {
|
||||
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
|
||||
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
|
||||
usePromotionStore().setPromotions(
|
||||
@@ -265,7 +265,9 @@ describe('Subgraph proxyWidgets', () => {
|
||||
|
||||
const serialized = subgraphNode.serialize()
|
||||
|
||||
expect(serialized.widgets_values).toEqual(['value'])
|
||||
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
|
||||
// Even if it were set, views have serialize: false and would be skipped.
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
|
||||
test('serialize preserves proxyWidgets in properties', () => {
|
||||
|
||||
@@ -186,16 +186,11 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
|
||||
if (!widget) return
|
||||
|
||||
// Special case: SubgraphNode widget.
|
||||
// Prefer serializeValue (per-instance) over the shared .value getter
|
||||
// so multiple SubgraphNode instances return their own configured values.
|
||||
const widgetValue = widget.serializeValue
|
||||
? widget.serializeValue(subgraphNode, -1)
|
||||
: widget.value
|
||||
return {
|
||||
node: this,
|
||||
origin_id: this.id,
|
||||
origin_slot: -1,
|
||||
widgetInfo: { value: widgetValue }
|
||||
widgetInfo: { value: widget.value }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode,
|
||||
resetSubgraphFixtureState
|
||||
} from './__fixtures__/subgraphHelpers'
|
||||
|
||||
function createNodeWithWidget(
|
||||
title: string,
|
||||
widgetValue: number = 42,
|
||||
slotType: ISlotType = 'number'
|
||||
) {
|
||||
const node = new LGraphNode(title)
|
||||
const input = node.addInput('value', slotType)
|
||||
node.addOutput('out', slotType)
|
||||
|
||||
const widget = node.addWidget('number', 'widget', widgetValue, () => {}, {
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1
|
||||
})
|
||||
input.widget = { name: widget.name }
|
||||
|
||||
return { node, widget, input }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
resetSubgraphFixtureState()
|
||||
})
|
||||
|
||||
describe('SubgraphNode multi-instance widget isolation', () => {
|
||||
it('preserves per-instance widget values after configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 201 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 202 })
|
||||
|
||||
// Simulate what LGraph.configure does: call configure with different widgets_values
|
||||
instance1.configure({
|
||||
id: 201,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [10]
|
||||
})
|
||||
|
||||
instance2.configure({
|
||||
id: 202,
|
||||
type: subgraph.id,
|
||||
pos: [400, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 1,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [20]
|
||||
})
|
||||
|
||||
const widgets1 = instance1.widgets!
|
||||
const widgets2 = instance2.widgets!
|
||||
|
||||
expect(widgets1.length).toBeGreaterThan(0)
|
||||
expect(widgets2.length).toBeGreaterThan(0)
|
||||
expect(widgets1[0].value).toBe(10)
|
||||
expect(widgets2[0].value).toBe(20)
|
||||
expect(widgets1[0].serializeValue!(instance1, 0)).toBe(10)
|
||||
expect(widgets2[0].serializeValue!(instance2, 0)).toBe(20)
|
||||
expect(instance1.serialize().widgets_values).toEqual([10])
|
||||
expect(instance2.serialize().widgets_values).toEqual([20])
|
||||
})
|
||||
|
||||
it('round-trips per-instance widget values through serialize and configure', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 301 })
|
||||
originalInstance.configure({
|
||||
id: 301,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 302 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 302,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
const restoredWidget = restoredInstance.widgets?.[0]
|
||||
expect(restoredWidget?.value).toBe(33)
|
||||
expect(restoredWidget?.serializeValue?.(restoredInstance, 0)).toBe(33)
|
||||
})
|
||||
|
||||
it('keeps fresh sibling instances isolated before save or reload', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node } = createNodeWithWidget('TestNode', 7)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance1 = createTestSubgraphNode(subgraph, { id: 401 })
|
||||
const instance2 = createTestSubgraphNode(subgraph, { id: 402 })
|
||||
instance1.graph!.add(instance1)
|
||||
instance2.graph!.add(instance2)
|
||||
|
||||
const widget1 = instance1.widgets?.[0]
|
||||
const widget2 = instance2.widgets?.[0]
|
||||
|
||||
expect(widget1?.value).toBe(7)
|
||||
expect(widget2?.value).toBe(7)
|
||||
|
||||
widget1!.value = 10
|
||||
|
||||
expect(widget1?.value).toBe(10)
|
||||
expect(widget2?.value).toBe(7)
|
||||
expect(widget1?.serializeValue?.(instance1, 0)).toBe(10)
|
||||
expect(widget2?.serializeValue?.(instance2, 0)).toBe(7)
|
||||
})
|
||||
|
||||
it('syncs restored promoted widgets when the inner source widget changes directly', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 0)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const originalInstance = createTestSubgraphNode(subgraph, { id: 601 })
|
||||
originalInstance.configure({
|
||||
id: 601,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: [33]
|
||||
})
|
||||
|
||||
const serialized = originalInstance.serialize()
|
||||
|
||||
const restoredInstance = createTestSubgraphNode(subgraph, { id: 602 })
|
||||
restoredInstance.configure({
|
||||
...serialized,
|
||||
id: 602,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(33)
|
||||
|
||||
widget.value = 45
|
||||
|
||||
expect(restoredInstance.widgets?.[0].value).toBe(45)
|
||||
expect(
|
||||
restoredInstance.widgets?.[0].serializeValue?.(restoredInstance, 0)
|
||||
).toBe(45)
|
||||
})
|
||||
|
||||
it('clears stale per-instance values when reconfigured without widgets_values', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 5)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 701 })
|
||||
instance.graph!.add(instance)
|
||||
|
||||
const promotedWidget = instance.widgets?.[0]
|
||||
promotedWidget!.value = 11
|
||||
widget.value = 17
|
||||
|
||||
const serialized = instance.serialize()
|
||||
delete serialized.widgets_values
|
||||
|
||||
instance.configure({
|
||||
...serialized,
|
||||
id: instance.id,
|
||||
type: subgraph.id
|
||||
})
|
||||
|
||||
expect(instance.widgets?.[0].value).toBe(17)
|
||||
expect(instance.widgets?.[0].serializeValue?.(instance, 0)).toBe(17)
|
||||
})
|
||||
|
||||
it('skips non-serializable source widgets during serialize', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'value', type: 'number' }]
|
||||
})
|
||||
|
||||
const { node, widget } = createNodeWithWidget('TestNode', 10)
|
||||
subgraph.add(node)
|
||||
subgraph.inputNode.slots[0].connect(node.inputs[0], node)
|
||||
|
||||
// Mark the source widget as non-persistent (e.g. preview widget)
|
||||
widget.serialize = false
|
||||
|
||||
const instance = createTestSubgraphNode(subgraph, { id: 501 })
|
||||
instance.configure({
|
||||
id: 501,
|
||||
type: subgraph.id,
|
||||
pos: [100, 100],
|
||||
size: [200, 100],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
mode: 0,
|
||||
order: 0,
|
||||
flags: {},
|
||||
properties: { proxyWidgets: [['-1', 'widget']] },
|
||||
widgets_values: []
|
||||
})
|
||||
|
||||
const serialized = instance.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -994,21 +994,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporarily stored during configure for use by _internalConfigureAfterSlots */
|
||||
private _pendingWidgetsValues?: unknown[]
|
||||
|
||||
/**
|
||||
* Per-instance promoted widget values.
|
||||
* Multiple SubgraphNode instances share the same inner nodes, so
|
||||
* promoted widget values must be stored per-instance to avoid collisions.
|
||||
* Key: `${sourceNodeId}:${sourceWidgetName}`
|
||||
*/
|
||||
readonly _instanceWidgetValues = new Map<string, unknown>()
|
||||
|
||||
override configure(info: ExportedSubgraphInstance): void {
|
||||
this._instanceWidgetValues.clear()
|
||||
this._pendingWidgetsValues = info.widgets_values
|
||||
|
||||
for (const input of this.inputs) {
|
||||
if (
|
||||
input._listenerController &&
|
||||
@@ -1139,21 +1125,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
|
||||
store.promote(this.rootGraph.id, this.id, source)
|
||||
}
|
||||
|
||||
// Hydrate per-instance promoted widget values from serialized data.
|
||||
// LGraphNode.configure skips promoted widgets (serialize === false on
|
||||
// the view), so they must be applied here after promoted views exist.
|
||||
// Only iterate serializable views to match what serialize() wrote.
|
||||
if (this._pendingWidgetsValues) {
|
||||
const views = this._getPromotedViews()
|
||||
let i = 0
|
||||
for (const view of views) {
|
||||
if (!view.sourceSerialize) continue
|
||||
if (i >= this._pendingWidgetsValues.length) break
|
||||
view.value = this._pendingWidgetsValues[i++] as typeof view.value
|
||||
}
|
||||
this._pendingWidgetsValues = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1548,7 +1519,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
override onRemoved(): void {
|
||||
this._eventAbortController.abort()
|
||||
this._invalidatePromotedViewsCache()
|
||||
this._instanceWidgetValues.clear()
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
@@ -1604,7 +1574,28 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes widget values from this SubgraphNode instance to the
|
||||
* corresponding widgets in the subgraph definition before serialization.
|
||||
* This ensures nested subgraph widget values are preserved when saving.
|
||||
*/
|
||||
override serialize(): ISerialisedNode {
|
||||
// Sync widget values to subgraph definition before serialization.
|
||||
// Only sync for inputs that are linked to a promoted widget via _widget.
|
||||
for (const input of this.inputs) {
|
||||
if (!input._widget) continue
|
||||
|
||||
const subgraphInput =
|
||||
input._subgraphSlot ??
|
||||
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
|
||||
if (!subgraphInput) continue
|
||||
|
||||
const connectedWidgets = subgraphInput.getConnectedWidgets()
|
||||
for (const connectedWidget of connectedWidgets) {
|
||||
connectedWidget.value = input._widget.value
|
||||
}
|
||||
}
|
||||
|
||||
// Write promotion store state back to properties for serialization
|
||||
const entries = usePromotionStore().getPromotions(
|
||||
this.rootGraph.id,
|
||||
@@ -1612,22 +1603,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
this.properties.proxyWidgets = this._serializeEntries(entries)
|
||||
|
||||
const serialized = super.serialize()
|
||||
const views = this._getPromotedViews()
|
||||
|
||||
const serializableViews = views.filter((view) => view.sourceSerialize)
|
||||
if (serializableViews.length > 0) {
|
||||
serialized.widgets_values = serializableViews.map((view) => {
|
||||
const value = view.serializeValue
|
||||
? view.serializeValue(this, -1)
|
||||
: view.value
|
||||
return value != null && typeof value === 'object'
|
||||
? JSON.parse(JSON.stringify(value))
|
||||
: (value ?? null)
|
||||
})
|
||||
}
|
||||
|
||||
return serialized
|
||||
return super.serialize()
|
||||
}
|
||||
override clone() {
|
||||
const clone = super.clone()
|
||||
|
||||
Reference in New Issue
Block a user