From dd1a1f77d643d7e341b420e8bce583e28506acd4 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Sat, 28 Feb 2026 13:45:04 -0800 Subject: [PATCH] fix: stabilize nested subgraph promoted widget resolution (#9282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fix multiple issues with promoted widget resolution in nested subgraphs, ensuring correct value propagation, slot matching, and rendering for deeply nested promoted widgets. ## Changes - **What**: Stabilize nested subgraph promoted widget resolution chain - Use deep source keys for promoted widget values in Vue rendering mode - Resolve effective widget options from the source widget instead of the promoted view - Stabilize slot resolution for nested promoted widgets - Preserve combo value rendering for promoted subgraph widgets - Prevent subgraph definition deletion while other nodes still reference the same type - Clean up unused exported resolution types ## Review Focus - `resolveConcretePromotedWidget.ts` — new recursive resolution logic for deeply nested promoted widgets - `useGraphNodeManager.ts` — option extraction now uses `effectiveWidget` for promoted widgets - `SubgraphNode.ts` — unpack no longer force-deletes definitions referenced by other nodes ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp Co-authored-by: GitHub Action --- .../subgraphs/subgraph-nested-promotion.json | 760 ++++++++++++++++++ browser_tests/tests/interaction.spec.ts | 1 + browser_tests/tests/subgraphPromotion.spec.ts | 68 ++ .../graph/widgets/DomWidget.test.ts | 116 +++ src/components/graph/widgets/DomWidget.vue | 10 +- .../graph/useGraphNodeManager.test.ts | 44 + src/composables/graph/useGraphNodeManager.ts | 133 ++- .../graph/subgraph/promotedWidgetTypes.ts | 8 +- .../graph/subgraph/promotedWidgetView.test.ts | 562 ++++++++++++- src/core/graph/subgraph/promotedWidgetView.ts | 133 ++- .../resolveConcretePromotedWidget.test.ts | 257 ++++++ .../subgraph/resolveConcretePromotedWidget.ts | 102 +++ .../subgraph/resolvePromotedWidgetSource.ts | 25 +- .../subgraph/resolveSubgraphInputLink.test.ts | 147 ++++ .../subgraph/resolveSubgraphInputLink.ts | 55 ++ .../subgraph/resolveSubgraphInputTarget.ts | 34 + src/lib/litegraph/src/LGraph.test.ts | 21 + src/lib/litegraph/src/LGraph.ts | 27 +- .../PromotedWidgetViewManager.test.ts | 55 +- .../src/subgraph/PromotedWidgetViewManager.ts | 52 +- .../litegraph/src/subgraph/SubgraphInput.ts | 6 +- .../litegraph/src/subgraph/SubgraphNode.ts | 355 +++++++- .../vueNodes/components/NodeWidgets.vue | 10 +- .../utils/resolvePromotedWidget.test.ts | 32 + 24 files changed, 2866 insertions(+), 147 deletions(-) create mode 100644 browser_tests/assets/subgraphs/subgraph-nested-promotion.json create mode 100644 src/components/graph/widgets/DomWidget.test.ts create mode 100644 src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts create mode 100644 src/core/graph/subgraph/resolveConcretePromotedWidget.ts create mode 100644 src/core/graph/subgraph/resolveSubgraphInputLink.test.ts create mode 100644 src/core/graph/subgraph/resolveSubgraphInputLink.ts create mode 100644 src/core/graph/subgraph/resolveSubgraphInputTarget.ts diff --git a/browser_tests/assets/subgraphs/subgraph-nested-promotion.json b/browser_tests/assets/subgraphs/subgraph-nested-promotion.json new file mode 100644 index 0000000000..22a964a5e8 --- /dev/null +++ b/browser_tests/assets/subgraphs/subgraph-nested-promotion.json @@ -0,0 +1,760 @@ +{ + "id": "9a37f747-e96b-4304-9212-7abcaad7bdac", + "revision": 0, + "last_node_id": 11, + "last_link_id": 18, + "nodes": [ + { + "id": 2, + "type": "PreviewAny", + "pos": [1031, 434], + "size": [250, 178], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "source", + "type": "*", + "link": 5 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [null, null, null] + }, + { + "id": 5, + "type": "1e38d8ea-45e1-48a5-aa20-966584201867", + "pos": [788, 433.5], + "size": [225, 380], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 4 + } + ], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [5] + } + ], + "properties": { + "proxyWidgets": [ + ["3", "string_a"], + ["4", "value"], + ["6", "value"], + ["6", "value_1"] + ] + }, + "widgets_values": [] + }, + { + "id": 1, + "type": "PrimitiveStringMultiline", + "pos": [548, 451], + "size": [225, 142], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "STRING", + "type": "STRING", + "links": [4] + } + ], + "title": "Outer", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": ["Outer\n"] + } + ], + "links": [ + [4, 1, 0, 5, 0, "STRING"], + [5, 5, 0, 2, 0, "STRING"] + ], + "groups": [], + "definitions": { + "subgraphs": [ + { + "id": "1e38d8ea-45e1-48a5-aa20-966584201867", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 11, + "lastLinkId": 18, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Sub 0", + "inputNode": { + "id": -10, + "bounding": [351, 432.5, 120, 120] + }, + "outputNode": { + "id": -20, + "bounding": [1352, 294.5, 120, 60] + }, + "inputs": [ + { + "id": "7bf3e1d4-0521-4b5c-92f5-47ca598b7eb4", + "name": "string_a", + "type": "STRING", + "linkIds": [1], + "localized_name": "string_a", + "pos": [451, 452.5] + }, + { + "id": "5fb3dcf7-9bfd-4b3c-a1b9-750b4f3edf19", + "name": "value", + "type": "STRING", + "linkIds": [13], + "pos": [451, 472.5] + }, + { + "id": "55d24b8a-7c82-4b02-8e3d-ff31ffb8aa13", + "name": "value_1", + "type": "STRING", + "linkIds": [16], + "pos": [451, 492.5] + }, + { + "id": "c1fe7cc3-547e-4fb0-b763-61888558d4bd", + "name": "value_1_1", + "type": "STRING", + "linkIds": [18], + "pos": [451, 512.5] + } + ], + "outputs": [ + { + "id": "fbe975ba-d7c2-471e-a99a-a1e2c6ab466d", + "name": "STRING", + "type": "STRING", + "linkIds": [9], + "localized_name": "STRING", + "pos": [1372, 314.5] + } + ], + "widgets": [], + "nodes": [ + { + "id": 4, + "type": "PrimitiveStringMultiline", + "pos": [504, 437], + "size": [210, 88], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 13 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [2] + } + ], + "title": "Inner 1", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": ["Inner 1\n"] + }, + { + "id": 3, + "type": "StringConcatenate", + "pos": [743, 325], + "size": [347, 231], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 1 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 2 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [7] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate" + }, + "widgets_values": ["", "", ""] + }, + { + "id": 6, + "type": "9be42452-056b-4c99-9f9f-7381d11c4454", + "pos": [1115, 301], + "size": [210, 196], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 7 + }, + { + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 16 + }, + { + "name": "value_1", + "type": "STRING", + "widget": { + "name": "value_1" + }, + "link": 18 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [9] + } + ], + "properties": { + "proxyWidgets": [ + ["5", "string_a"], + ["11", "value"], + ["9", "value"], + ["10", "string_a"] + ] + }, + "widgets_values": [] + } + ], + "groups": [], + "links": [ + { + "id": 2, + "origin_id": 4, + "origin_slot": 0, + "target_id": 3, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 1, + "origin_id": -10, + "origin_slot": 0, + "target_id": 3, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 7, + "origin_id": 3, + "origin_slot": 0, + "target_id": 6, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 6, + "origin_id": 6, + "origin_slot": 0, + "target_id": -20, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 9, + "origin_id": 6, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 13, + "origin_id": -10, + "origin_slot": 1, + "target_id": 4, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 16, + "origin_id": -10, + "origin_slot": 2, + "target_id": 6, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 18, + "origin_id": -10, + "origin_slot": 3, + "target_id": 6, + "target_slot": 2, + "type": "STRING" + } + ], + "extra": {} + }, + { + "id": "9be42452-056b-4c99-9f9f-7381d11c4454", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 11, + "lastLinkId": 18, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Sub 1", + "inputNode": { + "id": -10, + "bounding": [180, 739, 120, 100] + }, + "outputNode": { + "id": -20, + "bounding": [1246, 612, 120, 60] + }, + "inputs": [ + { + "id": "01c05c51-86b5-4bad-b32f-9c911683a13d", + "name": "string_a", + "type": "STRING", + "linkIds": [4], + "localized_name": "string_a", + "pos": [280, 759] + }, + { + "id": "d50f6a62-0185-43d4-a174-a8a94bd8f6e7", + "name": "value", + "type": "STRING", + "linkIds": [14], + "pos": [280, 779] + }, + { + "id": "6b78450e-5986-49cd-b743-c933e5a34a69", + "name": "value_1", + "type": "STRING", + "linkIds": [17], + "pos": [280, 799] + } + ], + "outputs": [ + { + "id": "a8bcf3bf-a66a-4c71-8d92-17a2a4d03686", + "name": "STRING", + "type": "STRING", + "linkIds": [12], + "localized_name": "STRING", + "pos": [1266, 632] + } + ], + "widgets": [], + "nodes": [ + { + "id": 11, + "type": "PrimitiveStringMultiline", + "pos": [334, 742], + "size": [210, 88], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 14 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [7] + } + ], + "title": "Inner 2", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": ["Inner 2\n"] + }, + { + "id": 10, + "type": "StringConcatenate", + "pos": [581, 637], + "size": [400, 200], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 4 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 7 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [11] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate" + }, + "widgets_values": ["", "", ""] + }, + { + "id": 9, + "type": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce", + "pos": [1004, 613], + "size": [210, 142], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 11 + }, + { + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 17 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [12] + } + ], + "properties": { + "proxyWidgets": [ + ["7", "string_a"], + ["8", "value"] + ] + }, + "widgets_values": [] + } + ], + "groups": [], + "links": [ + { + "id": 4, + "origin_id": -10, + "origin_slot": 0, + "target_id": 10, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 7, + "origin_id": 11, + "origin_slot": 0, + "target_id": 10, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 11, + "origin_id": 10, + "origin_slot": 0, + "target_id": 9, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 10, + "origin_id": 9, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 12, + "origin_id": 9, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 14, + "origin_id": -10, + "origin_slot": 1, + "target_id": 11, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 17, + "origin_id": -10, + "origin_slot": 2, + "target_id": 9, + "target_slot": 1, + "type": "STRING" + } + ], + "extra": {} + }, + { + "id": "7c2915a5-5eb8-4958-a8fd-4beb30f370ce", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 11, + "lastLinkId": 18, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Sub 2", + "inputNode": { + "id": -10, + "bounding": [262, 1222, 120, 80] + }, + "outputNode": { + "id": -20, + "bounding": [1123.089999999999, 1125.1999999999998, 120, 60] + }, + "inputs": [ + { + "id": "934a8baa-d79c-428c-8ec9-814ad437d7c7", + "name": "string_a", + "type": "STRING", + "linkIds": [9], + "localized_name": "string_a", + "pos": [362, 1242] + }, + { + "id": "3a545207-7202-42a9-a82f-3b62e1b0f459", + "name": "value", + "type": "STRING", + "linkIds": [15], + "pos": [362, 1262] + } + ], + "outputs": [ + { + "id": "4c3d243b-9ff6-4dcd-9dbf-e4ec8e1fc879", + "name": "STRING", + "type": "STRING", + "linkIds": [10], + "localized_name": "STRING", + "pos": [1143.089999999999, 1145.1999999999998] + } + ], + "widgets": [], + "nodes": [ + { + "id": 8, + "type": "PrimitiveStringMultiline", + "pos": [412.96000000000004, 1228.2399999999996], + "size": [210, 88], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 15 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [8] + } + ], + "title": "Inner 3", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": ["Inner 3\n"] + }, + { + "id": 7, + "type": "StringConcatenate", + "pos": [686.08, 1132.38], + "size": [400, 200], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 9 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [10] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate" + }, + "widgets_values": ["", "", ""] + } + ], + "groups": [], + "links": [ + { + "id": 8, + "origin_id": 8, + "origin_slot": 0, + "target_id": 7, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 9, + "origin_id": -10, + "origin_slot": 0, + "target_id": 7, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 10, + "origin_id": 7, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 15, + "origin_id": -10, + "origin_slot": 1, + "target_id": 8, + "target_slot": 0, + "type": "STRING" + } + ], + "extra": {} + } + ] + }, + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [-412, 11] + }, + "frontendVersion": "1.41.7" + }, + "version": 0.4 +} diff --git a/browser_tests/tests/interaction.spec.ts b/browser_tests/tests/interaction.spec.ts index 82d2033733..870642a67c 100644 --- a/browser_tests/tests/interaction.spec.ts +++ b/browser_tests/tests/interaction.spec.ts @@ -171,6 +171,7 @@ test.describe('Node Interaction', () => { test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.nodeOps.dragTextEncodeNode2() + await comfyPage.nextFrame() await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png') }) diff --git a/browser_tests/tests/subgraphPromotion.spec.ts b/browser_tests/tests/subgraphPromotion.spec.ts index d5dd233908..e770a00632 100644 --- a/browser_tests/tests/subgraphPromotion.spec.ts +++ b/browser_tests/tests/subgraphPromotion.spec.ts @@ -555,6 +555,74 @@ test.describe( }) }) + test.describe('Nested Promoted Widget Disabled State', () => { + test('Externally linked promoted widget is disabled, unlinked ones are not', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-nested-promotion' + ) + await comfyPage.nextFrame() + + // Node 5 (Sub 0) has 4 promoted widgets. The first (string_a) has its + // slot connected externally from the Outer node, so it should be + // disabled. The remaining promoted textarea widgets (value, value_1) + // are unlinked and should be enabled. + const promotedNames = await getPromotedWidgetNames(comfyPage, '5') + expect(promotedNames).toContain('string_a') + expect(promotedNames).toContain('value') + + const disabledState = await comfyPage.page.evaluate(() => { + const node = window.app!.canvas.graph!.getNodeById('5') + return (node?.widgets ?? []).map((w) => ({ + name: w.name, + disabled: !!w.computedDisabled + })) + }) + + const linkedWidget = disabledState.find((w) => w.name === 'string_a') + expect(linkedWidget?.disabled).toBe(true) + + const unlinkedWidgets = disabledState.filter( + (w) => w.name !== 'string_a' + ) + for (const w of unlinkedWidgets) { + expect(w.disabled).toBe(false) + } + }) + + test('Unlinked promoted textarea widgets are editable on the subgraph exterior', async ({ + comfyPage + }) => { + await comfyPage.workflow.loadWorkflow( + 'subgraphs/subgraph-nested-promotion' + ) + await comfyPage.nextFrame() + + // The promoted textareas that are NOT externally linked should be + // fully opaque and interactive. + const textareas = comfyPage.page.getByTestId( + TestIds.widgets.domWidgetTextarea + ) + await expect(textareas.first()).toBeVisible() + + const count = await textareas.count() + for (let i = 0; i < count; i++) { + const textarea = textareas.nth(i) + const wrapper = textarea.locator('..') + const opacity = await wrapper.evaluate( + (el) => getComputedStyle(el).opacity + ) + + if (opacity === '1' && (await textarea.isEditable())) { + const testContent = `nested-promotion-edit-${i}` + await textarea.fill(testContent) + await expect(textarea).toHaveValue(testContent) + } + } + }) + }) + test.describe('Promotion Cleanup', () => { test('Removing subgraph node clears promotion store entries', async ({ comfyPage diff --git a/src/components/graph/widgets/DomWidget.test.ts b/src/components/graph/widgets/DomWidget.test.ts new file mode 100644 index 0000000000..11121ed85b --- /dev/null +++ b/src/components/graph/widgets/DomWidget.test.ts @@ -0,0 +1,116 @@ +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { reactive } from 'vue' + +import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils' +import type { BaseDOMWidget } from '@/scripts/domWidget' +import type { DomWidgetState } from '@/stores/domWidgetStore' +import { useDomWidgetStore } from '@/stores/domWidgetStore' + +import DomWidget from './DomWidget.vue' + +const mockUpdatePosition = vi.fn() +const mockUpdateClipPath = vi.fn() +const mockCanvasElement = document.createElement('canvas') +const mockCanvasStore = { + canvas: { + graph: { + getNodeById: vi.fn(() => true) + }, + ds: { + offset: [0, 0], + scale: 1 + }, + canvas: mockCanvasElement, + selected_nodes: {} + }, + getCanvas: () => ({ canvas: mockCanvasElement }), + linearMode: false +} + +vi.mock('@/composables/element/useAbsolutePosition', () => ({ + useAbsolutePosition: () => ({ + style: reactive>({}), + updatePosition: mockUpdatePosition + }) +})) + +vi.mock('@/composables/element/useDomClipping', () => ({ + useDomClipping: () => ({ + style: reactive>({}), + updateClipPath: mockUpdateClipPath + }) +})) + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => mockCanvasStore +})) + +vi.mock('@/platform/settings/settingStore', () => ({ + useSettingStore: () => ({ + get: vi.fn(() => false) + }) +})) + +function createWidgetState(overrideDisabled: boolean): DomWidgetState { + const domWidgetStore = useDomWidgetStore() + const node = createMockLGraphNode({ + id: 1, + constructor: { + nodeData: {} + } + }) + + const widget = { + id: 'dom-widget-id', + name: 'test_widget', + type: 'custom', + value: '', + options: {}, + node, + computedDisabled: false + } as unknown as BaseDOMWidget + + domWidgetStore.registerWidget(widget) + domWidgetStore.setPositionOverride(widget.id, { + node: createMockLGraphNode({ id: 2 }), + widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget'] + }) + + const state = domWidgetStore.widgetStates.get(widget.id) + if (!state) throw new Error('Expected registered DomWidgetState') + + state.zIndex = 2 + state.size = [100, 40] + + return reactive(state) +} + +describe('DomWidget disabled style', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + afterEach(() => { + useDomWidgetStore().clear() + vi.clearAllMocks() + }) + + it('uses disabled style when promoted override widget is computedDisabled', async () => { + const widgetState = createWidgetState(true) + const wrapper = mount(DomWidget, { + props: { + widgetState + } + }) + + widgetState.zIndex = 3 + await wrapper.vm.$nextTick() + + const root = wrapper.get('.dom-widget').element as HTMLElement + expect(root.style.pointerEvents).toBe('none') + expect(root.style.opacity).toBe('0.5') + }) +}) diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue index 8c7a583481..7f97d04e13 100644 --- a/src/components/graph/widgets/DomWidget.vue +++ b/src/components/graph/widgets/DomWidget.vue @@ -110,13 +110,17 @@ watch( updateDomClipping() } + const override = widgetState.positionOverride + const isDisabled = override + ? (override.widget.computedDisabled ?? widget.computedDisabled) + : widget.computedDisabled + style.value = { ...positionStyle.value, ...(enableDomClipping.value ? clippingStyle.value : {}), zIndex: widgetState.zIndex, - pointerEvents: - widgetState.readonly || widget.computedDisabled ? 'none' : 'auto', - opacity: widget.computedDisabled ? 0.5 : 1 + pointerEvents: widgetState.readonly || isDisabled ? 'none' : 'auto', + opacity: isDisabled ? 0.5 : 1 } }, { deep: true } diff --git a/src/composables/graph/useGraphNodeManager.test.ts b/src/composables/graph/useGraphNodeManager.test.ts index 681d3ee1a5..39a25b2352 100644 --- a/src/composables/graph/useGraphNodeManager.test.ts +++ b/src/composables/graph/useGraphNodeManager.test.ts @@ -272,3 +272,47 @@ describe('Subgraph Promoted Pseudo Widgets', () => { expect(promotedWidget?.options?.canvasOnly).toBe(true) }) }) + +describe('Nested promoted widget mapping', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('maps store identity to deepest concrete widget for two-layer promotions', () => { + const subgraphA = createTestSubgraph({ + inputs: [{ name: 'a_input', type: '*' }] + }) + const innerNode = new LGraphNode('InnerComboNode') + const innerInput = innerNode.addInput('picker_input', '*') + innerNode.addWidget('combo', 'picker', 'a', () => undefined, { + values: ['a', 'b'] + }) + innerInput.widget = { name: 'picker' } + subgraphA.add(innerNode) + subgraphA.inputNode.slots[0].connect(innerInput, innerNode) + + const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 }) + + const subgraphB = createTestSubgraph({ + inputs: [{ name: 'b_input', type: '*' }] + }) + subgraphB.add(subgraphNodeA) + subgraphNodeA._internalConfigureAfterSlots() + subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + + const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 }) + const graph = subgraphNodeB.graph as LGraph + graph.add(subgraphNodeB) + + const { vueNodeData } = useGraphNodeManager(graph) + const nodeData = vueNodeData.get(String(subgraphNodeB.id)) + const mappedWidget = nodeData?.widgets?.[0] + + expect(mappedWidget).toBeDefined() + expect(mappedWidget?.type).toBe('combo') + expect(mappedWidget?.storeName).toBe('picker') + expect(mappedWidget?.storeNodeId).toBe( + `${subgraphNodeB.subgraph.id}:${innerNode.id}` + ) + }) +}) diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 71438e6acb..7a7a5900f7 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -7,7 +7,9 @@ import { reactive, shallowReactive } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' +import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget' import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource' +import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget' import type { INodeInputSlot, INodeOutputSlot @@ -46,7 +48,9 @@ export interface WidgetSlotMetadata { */ export interface SafeWidgetData { nodeId?: NodeId + storeNodeId?: NodeId name: string + storeName?: string type: string /** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */ callback?: ((value: unknown) => void) | undefined @@ -161,7 +165,7 @@ function getSharedWidgetEnhancements( /** * Validates that a value is a valid WidgetValue type */ -const normalizeWidgetValue = (value: unknown): WidgetValue => { +function normalizeWidgetValue(value: unknown): WidgetValue { if (value === null || value === undefined || value === void 0) { return undefined } @@ -193,11 +197,69 @@ function safeWidgetMapper( node: LGraphNode, slotMetadata: Map ): (widget: IBaseWidget) => SafeWidgetData { + function extractWidgetDisplayOptions( + widget: IBaseWidget + ): SafeWidgetData['options'] { + if (!widget.options) return undefined + + return { + canvasOnly: widget.options.canvasOnly, + advanced: widget.advanced, + hidden: widget.options.hidden, + read_only: widget.options.read_only + } + } + + function resolvePromotedSourceByInputName(inputName: string): { + sourceNodeId: string + sourceWidgetName: string + } | null { + const resolvedTarget = resolveSubgraphInputTarget(node, inputName) + if (!resolvedTarget) return null + + return { + sourceNodeId: resolvedTarget.nodeId, + sourceWidgetName: resolvedTarget.widgetName + } + } + + function resolvePromotedWidgetIdentity(widget: IBaseWidget): { + displayName: string + promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null + } { + if (!isPromotedWidgetView(widget)) { + return { + displayName: widget.name, + promotedSource: null + } + } + + const promotedInputName = node.inputs?.find((input) => { + if (input.name === widget.name) return true + if (input._widget === widget) return true + return false + })?.name + const displayName = promotedInputName ?? widget.name + const promotedSource = resolvePromotedSourceByInputName(displayName) ?? { + sourceNodeId: widget.sourceNodeId, + sourceWidgetName: widget.sourceWidgetName + } + + return { + displayName, + promotedSource + } + } + return function (widget) { try { + const { displayName, promotedSource } = + resolvePromotedWidgetIdentity(widget) + // Get shared enhancements (controlWidget, spec, nodeType) const sharedEnhancements = getSharedWidgetEnhancements(node, widget) - const slotInfo = slotMetadata.get(widget.name) + const slotInfo = + slotMetadata.get(displayName) ?? slotMetadata.get(widget.name) // Wrapper callback specific to Nodes 2.0 rendering const callback = (v: unknown) => { @@ -215,36 +277,52 @@ function safeWidgetMapper( isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$') // Extract only render-critical options (canvasOnly, advanced, read_only) - const options = widget.options - ? { - canvasOnly: widget.options.canvasOnly, - advanced: widget.advanced, - hidden: widget.options.hidden, - read_only: widget.options.read_only - } - : undefined + const options = extractWidgetDisplayOptions(widget) const subgraphId = node.isSubgraphNode() && node.subgraph.id + const resolvedSourceResult = + isPromotedWidgetView(widget) && promotedSource + ? resolveConcretePromotedWidget( + node, + promotedSource.sourceNodeId, + promotedSource.sourceWidgetName + ) + : null + const resolvedSource = + resolvedSourceResult?.status === 'resolved' + ? resolvedSourceResult.resolved + : undefined + const sourceWidget = resolvedSource?.widget + const sourceNode = resolvedSource?.node + + const effectiveWidget = sourceWidget ?? widget + const localId = isPromotedWidgetView(widget) - ? widget.sourceNodeId + ? String(sourceNode?.id ?? promotedSource?.sourceNodeId) : undefined const nodeId = subgraphId && localId ? `${subgraphId}:${localId}` : undefined - const name = isPromotedWidgetView(widget) - ? widget.sourceWidgetName - : widget.name + const storeName = isPromotedWidgetView(widget) + ? (sourceWidget?.name ?? promotedSource?.sourceWidgetName) + : undefined + const name = storeName ?? displayName return { nodeId, + storeNodeId: nodeId, name, - type: widget.type, + storeName, + type: effectiveWidget.type, ...sharedEnhancements, callback, - hasLayoutSize: typeof widget.computeLayoutSize === 'function', + hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function', isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget), options: isPromotedPseudoWidget - ? { ...options, canvasOnly: true } - : options, + ? { + ...(extractWidgetDisplayOptions(effectiveWidget) ?? options), + canvasOnly: true + } + : (extractWidgetDisplayOptions(effectiveWidget) ?? options), slotMetadata: slotInfo, slotName: name !== widget.name ? widget.name : undefined } @@ -312,14 +390,18 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData { }) const safeWidgets = reactiveComputed(() => { + const widgetsSnapshot = node.widgets ?? [] + + slotMetadata.clear() node.inputs?.forEach((input, index) => { - if (!input?.widget?.name) return - slotMetadata.set(input.widget.name, { + const slotInfo = { index, linked: input.link != null - }) + } + if (input.name) slotMetadata.set(input.name, slotInfo) + if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo) }) - return node.widgets?.map(safeWidgetMapper(node, slotMetadata)) ?? [] + return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata)) }) const nodeType = @@ -375,11 +457,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager { const slotMetadata = new Map() nodeRef.inputs?.forEach((input, index) => { - if (!input?.widget?.name) return - slotMetadata.set(input.widget.name, { + const slotInfo = { index, linked: input.link != null - }) + } + if (input.name) slotMetadata.set(input.name, slotInfo) + if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo) }) // Update only widgets with new slot metadata, keeping other widget data intact diff --git a/src/core/graph/subgraph/promotedWidgetTypes.ts b/src/core/graph/subgraph/promotedWidgetTypes.ts index 654cc5c1e8..c96773ae6d 100644 --- a/src/core/graph/subgraph/promotedWidgetTypes.ts +++ b/src/core/graph/subgraph/promotedWidgetTypes.ts @@ -1,5 +1,11 @@ -import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' + +export type ResolvedPromotedWidget = { + node: LGraphNode + widget: IBaseWidget +} export interface PromotedWidgetView extends IBaseWidget { readonly node: SubgraphNode diff --git a/src/core/graph/subgraph/promotedWidgetView.test.ts b/src/core/graph/subgraph/promotedWidgetView.test.ts index 30f150dea4..463c7747f3 100644 --- a/src/core/graph/subgraph/promotedWidgetView.test.ts +++ b/src/core/graph/subgraph/promotedWidgetView.test.ts @@ -17,6 +17,7 @@ import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' +import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource' import { usePromotionStore } from '@/stores/promotionStore' import { useWidgetValueStore } from '@/stores/widgetValueStore' @@ -121,11 +122,19 @@ describe(createPromotedWidgetView, () => { expect(view.serialize).toBe(false) }) - test('computedDisabled is false and setter is a no-op', () => { + test('computedDisabled defaults to false and accepts boolean values', () => { const [subgraphNode] = setupSubgraph() const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget') expect(view.computedDisabled).toBe(false) view.computedDisabled = true + expect(view.computedDisabled).toBe(true) + }) + + test('computedDisabled treats undefined as false', () => { + const [subgraphNode] = setupSubgraph() + const view = createPromotedWidgetView(subgraphNode, '1', 'myWidget') + view.computedDisabled = true + view.computedDisabled = undefined expect(view.computedDisabled).toBe(false) }) @@ -382,11 +391,173 @@ describe('SubgraphNode.widgets getter', () => { setActivePinia(createTestingPinia({ stubActions: false })) }) + test('defers promotions while subgraph node id is -1 and flushes on add', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'picker_input', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: -1 }) + + const innerNode = new LGraphNode('InnerNode') + const innerInput = innerNode.addInput('picker_input', '*') + innerNode.addWidget('combo', 'picker', 'a', () => {}, { + values: ['a', 'b'] + }) + innerInput.widget = { name: 'picker' } + subgraph.add(innerNode) + + subgraph.inputNode.slots[0].connect(innerInput, innerNode) + subgraphNode._internalConfigureAfterSlots() + + const store = usePromotionStore() + expect(store.getPromotions(subgraphNode.rootGraph.id, -1)).toStrictEqual([]) + + subgraphNode.graph?.add(subgraphNode) + expect(subgraphNode.id).not.toBe(-1) + expect( + store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id) + ).toStrictEqual([ + { + interiorNodeId: String(innerNode.id), + widgetName: 'picker' + } + ]) + }) + + test('rebinds one input to latest source without stale disconnected views', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'picker_input', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 41 }) + subgraphNode.graph?.add(subgraphNode) + + const firstNode = new LGraphNode('FirstNode') + const firstInput = firstNode.addInput('picker_input', '*') + firstNode.addWidget('combo', 'picker', 'a', () => {}, { + values: ['a', 'b'] + }) + firstInput.widget = { name: 'picker' } + subgraph.add(firstNode) + const subgraphInputSlot = subgraph.inputNode.slots[0] + subgraphInputSlot.connect(firstInput, firstNode) + + // Mirror user-driven rebind behavior: move the slot connection from first + // source to second source, rather than keeping both links connected. + subgraphInputSlot.disconnect() + + const secondNode = new LGraphNode('SecondNode') + const secondInput = secondNode.addInput('picker_input', '*') + secondNode.addWidget('combo', 'picker', 'b', () => {}, { + values: ['a', 'b'] + }) + secondInput.widget = { name: 'picker' } + subgraph.add(secondNode) + subgraphInputSlot.connect(secondInput, secondNode) + + const promotions = usePromotionStore().getPromotions( + subgraphNode.rootGraph.id, + subgraphNode.id + ) + + expect(promotions).toHaveLength(1) + expect(promotions[0]).toStrictEqual({ + interiorNodeId: String(secondNode.id), + widgetName: 'picker' + }) + expect(subgraphNode.widgets).toHaveLength(1) + expect(subgraphNode.widgets[0].value).toBe('b') + }) + + test('preserves distinct promoted display names when two inputs share one concrete widget name', () => { + const subgraph = createTestSubgraph({ + inputs: [ + { name: 'strength_model', type: '*' }, + { name: 'strength_model_1', type: '*' } + ] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 90 }) + subgraphNode.graph?.add(subgraphNode) + + const innerNode = new LGraphNode('InnerNumberNode') + const firstInput = innerNode.addInput('strength_model', '*') + const secondInput = innerNode.addInput('strength_model_1', '*') + innerNode.addWidget('number', 'strength_model', 1, () => {}) + firstInput.widget = { name: 'strength_model' } + secondInput.widget = { name: 'strength_model' } + subgraph.add(innerNode) + + subgraph.inputNode.slots[0].connect(firstInput, innerNode) + subgraph.inputNode.slots[1].connect(secondInput, innerNode) + + expect(subgraphNode.widgets).toHaveLength(2) + expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([ + 'strength_model', + 'strength_model_1' + ]) + }) + test('returns empty array when no proxyWidgets', () => { const [subgraphNode] = setupSubgraph() expect(subgraphNode.widgets).toEqual([]) }) + test('widgets getter prefers live linked entries over stale store entries', () => { + const subgraph = createTestSubgraph({ + inputs: [{ name: 'widgetA', type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 91 }) + subgraphNode.graph?.add(subgraphNode) + + const liveNode = new LGraphNode('LiveNode') + const liveInput = liveNode.addInput('widgetA', '*') + liveNode.addWidget('text', 'widgetA', 'a', () => {}) + liveInput.widget = { name: 'widgetA' } + subgraph.add(liveNode) + subgraph.inputNode.slots[0].connect(liveInput, liveNode) + + setPromotions(subgraphNode, [ + [String(liveNode.id), 'widgetA'], + ['9999', 'missingWidget'] + ]) + + expect(subgraphNode.widgets).toHaveLength(1) + expect(subgraphNode.widgets[0].name).toBe('widgetA') + }) + + test('partial linked coverage does not destructively prune unresolved store promotions', () => { + const subgraph = createTestSubgraph({ + inputs: [ + { name: 'widgetA', type: '*' }, + { name: 'widgetB', type: '*' } + ] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 92 }) + subgraphNode.graph?.add(subgraphNode) + + const liveNode = new LGraphNode('LiveNode') + const liveInput = liveNode.addInput('widgetA', '*') + liveNode.addWidget('text', 'widgetA', 'a', () => {}) + liveInput.widget = { name: 'widgetA' } + subgraph.add(liveNode) + subgraph.inputNode.slots[0].connect(liveInput, liveNode) + + setPromotions(subgraphNode, [ + [String(liveNode.id), 'widgetA'], + ['9999', 'widgetB'] + ]) + + // Trigger widgets getter reconciliation in partial-linked state. + void subgraphNode.widgets + + const promotions = usePromotionStore().getPromotions( + subgraphNode.rootGraph.id, + subgraphNode.id + ) + expect(promotions).toStrictEqual([ + { interiorNodeId: String(liveNode.id), widgetName: 'widgetA' }, + { interiorNodeId: '9999', widgetName: 'widgetB' } + ]) + }) + test('caches view objects across getter calls (stable references)', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'widgetA', 'a', () => {}) @@ -741,7 +912,7 @@ describe('disconnected state', () => { expect(subgraphNode.widgets[0].type).toBe('number') }) - test('view falls back to button type when interior node is removed', () => { + test('keeps promoted entry as disconnected when interior node is removed', () => { const [subgraphNode, innerNodes] = setupSubgraph(1) innerNodes[0].addWidget('text', 'myWidget', 'val', () => {}) setPromotions(subgraphNode, [['1', 'myWidget']]) @@ -750,6 +921,7 @@ describe('disconnected state', () => { // Remove the interior node from the subgraph subgraphNode.subgraph.remove(innerNodes[0]) + expect(subgraphNode.widgets).toHaveLength(1) expect(subgraphNode.widgets[0].type).toBe('button') }) @@ -767,16 +939,11 @@ describe('disconnected state', () => { expect(subgraphNode.widgets[0].type).toBe('text') }) - test('options returns empty object when disconnected', () => { + test('keeps missing source-node promotions as disconnected views', () => { const [subgraphNode] = setupSubgraph() setPromotions(subgraphNode, [['999', 'ghost']]) - expect(subgraphNode.widgets[0].options).toEqual({}) - }) - - test('tooltip returns undefined when disconnected', () => { - const [subgraphNode] = setupSubgraph() - setPromotions(subgraphNode, [['999', 'ghost']]) - expect(subgraphNode.widgets[0].tooltip).toBeUndefined() + expect(subgraphNode.widgets).toHaveLength(1) + expect(subgraphNode.widgets[0].type).toBe('button') }) }) @@ -786,6 +953,381 @@ function createFakeCanvasContext() { }) } +function createInspectableCanvasContext(fillText = vi.fn()) { + const fallback = vi.fn() + return new Proxy( + { + fillText, + beginPath: vi.fn(), + roundRect: vi.fn(), + rect: vi.fn(), + fill: vi.fn(), + stroke: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + arc: vi.fn(), + measureText: (text: string) => ({ width: text.length * 8 }), + fillStyle: '#fff', + strokeStyle: '#fff', + textAlign: 'left', + globalAlpha: 1, + lineWidth: 1 + } as Record, + { + get(target, key) { + if (typeof key === 'string' && key in target) + return target[key as keyof typeof target] + return fallback + } + } + ) as unknown as CanvasRenderingContext2D +} + +function createTwoLevelNestedSubgraph() { + const subgraphA = createTestSubgraph({ + inputs: [{ name: 'a_input', type: '*' }] + }) + const innerNode = new LGraphNode('InnerComboNode') + const innerInput = innerNode.addInput('picker_input', '*') + const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, { + values: ['a', 'b'] + }) + innerInput.widget = { name: 'picker' } + subgraphA.add(innerNode) + subgraphA.inputNode.slots[0].connect(innerInput, innerNode) + + const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 }) + + const subgraphB = createTestSubgraph({ + inputs: [{ name: 'b_input', type: '*' }] + }) + subgraphB.add(subgraphNodeA) + subgraphNodeA._internalConfigureAfterSlots() + subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + + const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 }) + return { innerNode, comboWidget, subgraphNodeB } +} + +describe('promoted combo rendering', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + test('draw shows value even when interior combo is computedDisabled', () => { + const [subgraphNode, innerNodes] = setupSubgraph(1) + const innerNode = firstInnerNode(innerNodes) + const comboWidget = innerNode.addWidget('combo', 'picker', 'a', () => {}, { + values: ['a', 'b'] + }) + + // Simulates source widgets connected to subgraph inputs. + comboWidget.computedDisabled = true + setPromotions(subgraphNode, [[String(innerNode.id), 'picker']]) + + const fillText = vi.fn() + const ctx = createInspectableCanvasContext(fillText) + subgraphNode.widgets[0].draw?.( + ctx, + subgraphNode, + 260, + 0, + LiteGraph.NODE_WIDGET_HEIGHT, + false + ) + + const renderedText = fillText.mock.calls.map((call) => call[0]) + expect(renderedText).toContain('a') + }) + + test('draw shows value through two input-based promotion layers', () => { + const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph() + comboWidget.computedDisabled = true + const fillText = vi.fn() + const ctx = createInspectableCanvasContext(fillText) + + subgraphNodeB.widgets[0].draw?.( + ctx, + subgraphNodeB, + 260, + 0, + LiteGraph.NODE_WIDGET_HEIGHT, + false + ) + + const renderedText = fillText.mock.calls.map((call) => call[0]) + expect(renderedText).toContain('a') + }) + + test('value updates propagate through two promoted input layers', () => { + const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph() + comboWidget.computedDisabled = true + const promotedWidget = subgraphNodeB.widgets[0] + + expect(promotedWidget.value).toBe('a') + promotedWidget.value = 'b' + expect(comboWidget.value).toBe('b') + + const fillText = vi.fn() + const ctx = createInspectableCanvasContext(fillText) + promotedWidget.draw?.( + ctx, + subgraphNodeB, + 260, + 0, + LiteGraph.NODE_WIDGET_HEIGHT, + false + ) + + const renderedText = fillText.mock.calls.map((call) => call[0]) + expect(renderedText).toContain('b') + }) + + test('draw projection recovers after transient button fallback in nested promotion', () => { + const { innerNode, subgraphNodeB } = createTwoLevelNestedSubgraph() + const promotedWidget = subgraphNodeB.widgets[0] + + // Force a transient disconnect to project a fallback widget once. + innerNode.widgets = [] + promotedWidget.draw?.( + createInspectableCanvasContext(), + subgraphNodeB, + 260, + 0, + LiteGraph.NODE_WIDGET_HEIGHT, + false + ) + + // Restore the concrete widget and verify draw reflects recovery. + innerNode.addWidget('combo', 'picker', 'a', () => {}, { + values: ['a', 'b'] + }) + const fillText = vi.fn() + promotedWidget.draw?.( + createInspectableCanvasContext(fillText), + subgraphNodeB, + 260, + 0, + LiteGraph.NODE_WIDGET_HEIGHT, + false + ) + + const renderedText = fillText.mock.calls.map((call) => call[0]) + expect(renderedText).toContain('a') + }) + + test('state lookup behavior resolves to deepest promoted widget source', () => { + const { comboWidget, subgraphNodeB } = createTwoLevelNestedSubgraph() + + const promotedWidget = subgraphNodeB.widgets[0] + expect(promotedWidget.value).toBe('a') + + comboWidget.value = 'b' + expect(promotedWidget.value).toBe('b') + }) + + test('state lookup does not use promotion store fallback when intermediate view is unavailable', () => { + const subgraphA = createTestSubgraph({ + inputs: [{ name: 'strength_model', type: '*' }] + }) + const innerNode = new LGraphNode('InnerNumberNode') + const innerInput = innerNode.addInput('strength_model', '*') + innerNode.addWidget('number', 'strength_model', 1, () => {}) + innerInput.widget = { name: 'strength_model' } + subgraphA.add(innerNode) + subgraphA.inputNode.slots[0].connect(innerInput, innerNode) + + const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 }) + + const subgraphB = createTestSubgraph({ + inputs: [{ name: 'strength_model', type: '*' }] + }) + subgraphB.add(subgraphNodeA) + subgraphNodeA._internalConfigureAfterSlots() + subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + + const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 }) + + // Simulate transient stale intermediate view state by forcing host 47 + // to report no promoted widgets while promotionStore still has entries. + Object.defineProperty(subgraphNodeA, 'widgets', { + get: () => [], + configurable: true + }) + + expect(subgraphNodeB.widgets[0].type).toBe('button') + }) + + test('state lookup does not use input-widget fallback when intermediate promotions are absent', () => { + const subgraphA = createTestSubgraph({ + inputs: [{ name: 'strength_model', type: '*' }] + }) + const innerNode = new LGraphNode('InnerNumberNode') + const innerInput = innerNode.addInput('strength_model', '*') + innerNode.addWidget('number', 'strength_model', 1, () => {}) + innerInput.widget = { name: 'strength_model' } + subgraphA.add(innerNode) + subgraphA.inputNode.slots[0].connect(innerInput, innerNode) + + const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 }) + + const subgraphB = createTestSubgraph({ + inputs: [{ name: 'strength_model', type: '*' }] + }) + subgraphB.add(subgraphNodeA) + subgraphNodeA._internalConfigureAfterSlots() + subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + + const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 }) + + // Simulate a transient where intermediate promotions are unavailable but + // input _widget binding is already updated. + usePromotionStore().setPromotions( + subgraphNodeA.rootGraph.id, + subgraphNodeA.id, + [] + ) + Object.defineProperty(subgraphNodeA, 'widgets', { + get: () => [], + configurable: true + }) + + expect(subgraphNodeB.widgets[0].type).toBe('button') + }) + + test('state lookup does not use subgraph-link fallback when intermediate bindings are unavailable', () => { + const subgraphA = createTestSubgraph({ + inputs: [{ name: 'strength_model', type: '*' }] + }) + const innerNode = new LGraphNode('InnerNumberNode') + const innerInput = innerNode.addInput('strength_model', '*') + innerNode.addWidget('number', 'strength_model', 1, () => {}) + innerInput.widget = { name: 'strength_model' } + subgraphA.add(innerNode) + subgraphA.inputNode.slots[0].connect(innerInput, innerNode) + + const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 47 }) + + const subgraphB = createTestSubgraph({ + inputs: [{ name: 'strength_model', type: '*' }] + }) + subgraphB.add(subgraphNodeA) + subgraphNodeA._internalConfigureAfterSlots() + subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + + const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 46 }) + + usePromotionStore().setPromotions( + subgraphNodeA.rootGraph.id, + subgraphNodeA.id, + [] + ) + Object.defineProperty(subgraphNodeA, 'widgets', { + get: () => [], + configurable: true + }) + subgraphNodeA.inputs[0]._widget = undefined + + expect(subgraphNodeB.widgets[0].type).toBe('button') + }) + + test('nested promotion keeps concrete widget types at top level', () => { + const subgraphA = createTestSubgraph({ + inputs: [ + { name: 'lora_name', type: '*' }, + { name: 'strength_model', type: '*' } + ] + }) + const innerNode = new LGraphNode('InnerLoraNode') + const comboInput = innerNode.addInput('lora_name', '*') + const numberInput = innerNode.addInput('strength_model', '*') + innerNode.addWidget('combo', 'lora_name', 'a', () => {}, { + values: ['a', 'b'] + }) + innerNode.addWidget('number', 'strength_model', 1, () => {}) + comboInput.widget = { name: 'lora_name' } + numberInput.widget = { name: 'strength_model' } + subgraphA.add(innerNode) + subgraphA.inputNode.slots[0].connect(comboInput, innerNode) + subgraphA.inputNode.slots[1].connect(numberInput, innerNode) + + const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 60 }) + + const subgraphB = createTestSubgraph({ + inputs: [ + { name: 'lora_name', type: '*' }, + { name: 'strength_model', type: '*' } + ] + }) + subgraphB.add(subgraphNodeA) + subgraphNodeA._internalConfigureAfterSlots() + subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + subgraphB.inputNode.slots[1].connect(subgraphNodeA.inputs[1], subgraphNodeA) + + const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 61 }) + + expect(subgraphNodeB.widgets[0].type).toBe('combo') + expect(subgraphNodeB.widgets[1].type).toBe('number') + }) + + test('input promotion from promoted view stores immediate source node id', () => { + const subgraphA = createTestSubgraph({ + inputs: [{ name: 'lora_name', type: '*' }] + }) + const innerNode = new LGraphNode('InnerNode') + const innerInput = innerNode.addInput('lora_name', '*') + innerNode.addWidget('combo', 'lora_name', 'a', () => {}, { + values: ['a', 'b'] + }) + innerInput.widget = { name: 'lora_name' } + subgraphA.add(innerNode) + subgraphA.inputNode.slots[0].connect(innerInput, innerNode) + + const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 70 }) + + const subgraphB = createTestSubgraph({ + inputs: [{ name: 'lora_name', type: '*' }] + }) + subgraphB.add(subgraphNodeA) + subgraphNodeA._internalConfigureAfterSlots() + subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA) + + const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 71 }) + const promotions = usePromotionStore().getPromotions( + subgraphNodeB.rootGraph.id, + subgraphNodeB.id + ) + + expect(promotions).toContainEqual({ + interiorNodeId: String(subgraphNodeA.id), + widgetName: 'lora_name' + }) + expect(promotions).not.toContainEqual({ + interiorNodeId: String(innerNode.id), + widgetName: 'lora_name' + }) + }) + + test('resolvePromotedWidgetSource is safe for detached subgraph hosts', () => { + const subgraph = createTestSubgraph() + const subgraphNode = createTestSubgraphNode(subgraph, { id: 101 }) + const promotedView = createPromotedWidgetView( + subgraphNode, + '999', + 'missingWidget' + ) + + subgraphNode.graph = null + + expect(() => + resolvePromotedWidgetSource(subgraphNode, promotedView) + ).not.toThrow() + expect( + resolvePromotedWidgetSource(subgraphNode, promotedView) + ).toBeUndefined() + }) +}) + describe('DOM widget promotion', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) diff --git a/src/core/graph/subgraph/promotedWidgetView.ts b/src/core/graph/subgraph/promotedWidgetView.ts index aae35d5b0a..4a404d55ba 100644 --- a/src/core/graph/subgraph/promotedWidgetView.ts +++ b/src/core/graph/subgraph/promotedWidgetView.ts @@ -1,4 +1,4 @@ -import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode' +import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer' import type { Point } from '@/lib/litegraph/src/interfaces' @@ -13,23 +13,16 @@ import { stripGraphPrefix, useWidgetValueStore } from '@/stores/widgetValueStore' +import { + resolveConcretePromotedWidget, + resolvePromotedWidgetAtHost +} from '@/core/graph/subgraph/resolveConcretePromotedWidget' import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes' export type { PromotedWidgetView } from './promotedWidgetTypes' export { isPromotedWidgetView } from './promotedWidgetTypes' -function resolve( - subgraphNode: SubgraphNode, - nodeId: string, - widgetName: string -): { node: LGraphNode; widget: IBaseWidget } | undefined { - const node = subgraphNode.subgraph.getNodeById(nodeId) - if (!node) return undefined - const widget = node.widgets?.find((w: IBaseWidget) => w.name === widgetName) - return widget ? { node, widget } : undefined -} - function isWidgetValue(value: unknown): value is IBaseWidget['value'] { if (value === undefined) return true if (typeof value === 'string') return true @@ -46,6 +39,8 @@ function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget { return 'mouse' in widget && typeof widget.mouse === 'function' } +const designTokenCache = new Map() + export function createPromotedWidgetView( subgraphNode: SubgraphNode, nodeId: string, @@ -67,12 +62,15 @@ class PromotedWidgetView implements IPromotedWidgetView { computedHeight?: number private readonly graphId: string - private readonly bareNodeId: NodeId private yValue = 0 + private _computedDisabled = false private projectedSourceNode?: LGraphNode private projectedSourceWidget?: IBaseWidget + private projectedSourceWidgetType?: IBaseWidget['type'] private projectedWidget?: BaseWidget + private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget } + private cachedDeepestFrame = -1 constructor( private readonly subgraphNode: SubgraphNode, @@ -83,7 +81,6 @@ class PromotedWidgetView implements IPromotedWidgetView { this.sourceNodeId = nodeId this.sourceWidgetName = widgetName this.graphId = subgraphNode.rootGraph.id - this.bareNodeId = stripGraphPrefix(nodeId) } get node(): SubgraphNode { @@ -103,32 +100,34 @@ class PromotedWidgetView implements IPromotedWidgetView { this.syncDomOverride() } - get computedDisabled(): false { - return false + get computedDisabled(): boolean { + return this._computedDisabled } - set computedDisabled(_value: boolean | undefined) {} + set computedDisabled(value: boolean | undefined) { + this._computedDisabled = value ?? false + } get type(): IBaseWidget['type'] { - return this.resolve()?.widget.type ?? 'button' + return this.resolveDeepest()?.widget.type ?? 'button' } get options(): IBaseWidget['options'] { - return this.resolve()?.widget.options ?? {} + return this.resolveDeepest()?.widget.options ?? {} } get tooltip(): string | undefined { - return this.resolve()?.widget.tooltip + return this.resolveDeepest()?.widget.tooltip } get linkedWidgets(): IBaseWidget[] | undefined { - return this.resolve()?.widget.linkedWidgets + return this.resolveDeepest()?.widget.linkedWidgets } get value(): IBaseWidget['value'] { const state = this.getWidgetState() if (state && isWidgetValue(state.value)) return state.value - return this.resolve()?.widget.value + return this.resolveAtHost()?.widget.value } set value(value: IBaseWidget['value']) { @@ -138,7 +137,7 @@ class PromotedWidgetView implements IPromotedWidgetView { return } - const resolved = this.resolve() + const resolved = this.resolveAtHost() if (resolved && isWidgetValue(value)) { resolved.widget.value = value } @@ -155,18 +154,18 @@ class PromotedWidgetView implements IPromotedWidgetView { } get hidden(): boolean { - return this.resolve()?.widget.hidden ?? false + return this.resolveDeepest()?.widget.hidden ?? false } get computeLayoutSize(): IBaseWidget['computeLayoutSize'] { - const resolved = this.resolve() + const resolved = this.resolveDeepest() const computeLayoutSize = resolved?.widget.computeLayoutSize if (!computeLayoutSize) return undefined return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node) } get computeSize(): IBaseWidget['computeSize'] { - const resolved = this.resolve() + const resolved = this.resolveDeepest() const computeSize = resolved?.widget.computeSize if (!computeSize) return undefined return (width?: number) => computeSize.call(resolved.widget, width) @@ -180,7 +179,7 @@ class PromotedWidgetView implements IPromotedWidgetView { H: number, lowQuality?: boolean ): void { - const resolved = this.resolve() + const resolved = this.resolveDeepest() if (!resolved) { drawDisconnectedPlaceholder(ctx, widgetWidth, y, H) return @@ -193,9 +192,11 @@ class PromotedWidgetView implements IPromotedWidgetView { const originalY = projected.y const originalComputedHeight = projected.computedHeight + const originalComputedDisabled = projected.computedDisabled projected.y = this.y projected.computedHeight = this.computedHeight + projected.computedDisabled = this.computedDisabled projected.value = this.value projected.drawWidget(ctx, { @@ -207,6 +208,7 @@ class PromotedWidgetView implements IPromotedWidgetView { projected.y = originalY projected.computedHeight = originalComputedHeight + projected.computedDisabled = originalComputedDisabled } onPointerDown( @@ -214,7 +216,7 @@ class PromotedWidgetView implements IPromotedWidgetView { _node: LGraphNode, canvas: LGraphCanvas ): boolean { - const resolved = this.resolve() + const resolved = this.resolveAtHost() if (!resolved) return false const interior = resolved.widget @@ -240,18 +242,48 @@ class PromotedWidgetView implements IPromotedWidgetView { pos?: Point, e?: CanvasPointerEvent ) { - this.resolve()?.widget.callback?.(value, canvas, node, pos, e) + this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e) } - private resolve(): { node: LGraphNode; widget: IBaseWidget } | undefined { - return resolve(this.subgraphNode, this.sourceNodeId, this.sourceWidgetName) + private resolveAtHost(): + | { node: LGraphNode; widget: IBaseWidget } + | undefined { + return resolvePromotedWidgetAtHost( + this.subgraphNode, + this.sourceNodeId, + this.sourceWidgetName + ) + } + + private resolveDeepest(): + | { node: LGraphNode; widget: IBaseWidget } + | undefined { + const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame + if (frame !== undefined && this.cachedDeepestFrame === frame) + return this.cachedDeepestByFrame + + const result = resolveConcretePromotedWidget( + this.subgraphNode, + this.sourceNodeId, + this.sourceWidgetName + ) + const resolved = result.status === 'resolved' ? result.resolved : undefined + + if (frame !== undefined) { + this.cachedDeepestFrame = frame + this.cachedDeepestByFrame = resolved + } + + return resolved } private getWidgetState() { + const resolved = this.resolveDeepest() + if (!resolved) return undefined return useWidgetValueStore().getWidget( this.graphId, - this.bareNodeId, - this.sourceWidgetName + stripGraphPrefix(String(resolved.node.id)), + resolved.widget.name ) } @@ -262,7 +294,8 @@ class PromotedWidgetView implements IPromotedWidgetView { const shouldRebuild = !this.projectedWidget || this.projectedSourceNode !== resolved.node || - this.projectedSourceWidget !== resolved.widget + this.projectedSourceWidget !== resolved.widget || + this.projectedSourceWidgetType !== resolved.widget.type if (!shouldRebuild) return this.projectedWidget @@ -271,12 +304,14 @@ class PromotedWidgetView implements IPromotedWidgetView { this.projectedWidget = undefined this.projectedSourceNode = undefined this.projectedSourceWidget = undefined + this.projectedSourceWidgetType = undefined return undefined } this.projectedWidget = concrete.createCopyForNode(this.subgraphNode) this.projectedSourceNode = resolved.node this.projectedSourceWidget = resolved.widget + this.projectedSourceWidgetType = resolved.widget.type return this.projectedWidget } @@ -333,7 +368,7 @@ class PromotedWidgetView implements IPromotedWidgetView { private syncDomOverride( resolved: | { node: LGraphNode; widget: IBaseWidget } - | undefined = this.resolve() + | undefined = this.resolveAtHost() ) { if (!resolved || !isBaseDOMWidget(resolved.widget)) return useDomWidgetStore().setPositionOverride(resolved.widget.id, { @@ -356,13 +391,35 @@ function drawDisconnectedPlaceholder( y: number, H: number ) { + const backgroundColor = readDesignToken( + '--color-secondary-background', + '#333' + ) + const textColor = readDesignToken('--color-text-secondary', '#999') + const fontSize = readDesignToken('--text-xxs', '11px') + const fontFamily = readDesignToken('--font-inter', 'sans-serif') + ctx.save() - ctx.fillStyle = '#333' + ctx.fillStyle = backgroundColor ctx.fillRect(15, y, width - 30, H) - ctx.fillStyle = '#999' - ctx.font = '11px monospace' + ctx.fillStyle = textColor + ctx.font = `${fontSize} ${fontFamily}` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2) ctx.restore() } + +function readDesignToken(token: string, fallback: string): string { + if (typeof document === 'undefined') return fallback + + const cachedValue = designTokenCache.get(token) + if (cachedValue) return cachedValue + + const value = getComputedStyle(document.documentElement) + .getPropertyValue(token) + .trim() + const resolvedValue = value || fallback + designTokenCache.set(token, resolvedValue) + return resolvedValue +} diff --git a/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts b/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts new file mode 100644 index 0000000000..ca934ecc0b --- /dev/null +++ b/src/core/graph/subgraph/resolveConcretePromotedWidget.test.ts @@ -0,0 +1,257 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { + resolveConcretePromotedWidget, + resolvePromotedWidgetAtHost +} from '@/core/graph/subgraph/resolveConcretePromotedWidget' +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import { + createTestSubgraph, + createTestSubgraphNode +} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({}) +})) +vi.mock('@/stores/domWidgetStore', () => ({ + useDomWidgetStore: () => ({ widgetStates: new Map() }) +})) +vi.mock('@/services/litegraphService', () => ({ + useLitegraphService: () => ({ updatePreviews: () => ({}) }) +})) + +type PromotedWidgetStub = Pick< + IBaseWidget, + 'name' | 'type' | 'options' | 'value' | 'y' +> & { + sourceNodeId: string + sourceWidgetName: string + node?: SubgraphNode +} + +function createHostNode(id: number): SubgraphNode { + return createTestSubgraphNode(createTestSubgraph(), { id }) +} + +function addNodeToHost(host: SubgraphNode, title: string): LGraphNode { + const node = new LGraphNode(title) + host.subgraph.add(node) + return node +} + +function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget { + return node.addWidget('text', name, `${name}-value`, () => undefined) +} + +function createPromotedWidget( + name: string, + sourceNodeId: string, + sourceWidgetName: string, + node?: SubgraphNode +): IBaseWidget { + const promotedWidget: PromotedWidgetStub = { + name, + type: 'button', + options: {}, + y: 0, + value: undefined, + sourceNodeId, + sourceWidgetName, + node + } + return promotedWidget as IBaseWidget +} + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) +}) + +describe('resolvePromotedWidgetAtHost', () => { + test('resolves a direct concrete widget on the host subgraph node', () => { + const host = createHostNode(100) + const concreteNode = addNodeToHost(host, 'leaf') + addConcreteWidget(concreteNode, 'seed') + + const resolved = resolvePromotedWidgetAtHost( + host, + String(concreteNode.id), + 'seed' + ) + + expect(resolved).toBeDefined() + expect(resolved?.node.id).toBe(concreteNode.id) + expect(resolved?.widget.name).toBe('seed') + }) + + test('returns undefined when host does not contain the target node', () => { + const host = createHostNode(100) + + const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed') + + expect(resolved).toBeUndefined() + }) +}) + +describe('resolveConcretePromotedWidget', () => { + test('resolves a direct concrete source widget', () => { + const host = createHostNode(100) + const concreteNode = addNodeToHost(host, 'leaf') + addConcreteWidget(concreteNode, 'seed') + + const result = resolveConcretePromotedWidget( + host, + String(concreteNode.id), + 'seed' + ) + + expect(result.status).toBe('resolved') + if (result.status !== 'resolved') return + expect(result.resolved.node.id).toBe(concreteNode.id) + expect(result.resolved.widget.name).toBe('seed') + }) + + test('descends through nested promoted widgets to resolve concrete source', () => { + const rootHost = createHostNode(100) + const nestedHost = createHostNode(101) + const leafNode = addNodeToHost(nestedHost, 'leaf') + addConcreteWidget(leafNode, 'seed') + const sourceNode = addNodeToHost(rootHost, 'source') + sourceNode.widgets = [ + createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost) + ] + + const result = resolveConcretePromotedWidget( + rootHost, + String(sourceNode.id), + 'outer' + ) + + expect(result.status).toBe('resolved') + if (result.status !== 'resolved') return + expect(result.resolved.node.id).toBe(leafNode.id) + expect(result.resolved.widget.name).toBe('seed') + }) + + test('returns cycle failure when promoted widgets form a loop', () => { + const hostA = createHostNode(200) + const hostB = createHostNode(201) + const relayA = addNodeToHost(hostA, 'relayA') + const relayB = addNodeToHost(hostB, 'relayB') + + relayA.widgets = [ + createPromotedWidget('wA', String(relayB.id), 'wB', hostB) + ] + relayB.widgets = [ + createPromotedWidget('wB', String(relayA.id), 'wA', hostA) + ] + + const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA') + + expect(result).toEqual({ + status: 'failure', + failure: 'cycle' + }) + }) + + test('does not report a cycle when different host objects share an id', () => { + const rootHost = createHostNode(41) + const nestedHost = createHostNode(41) + const leafNode = addNodeToHost(nestedHost, 'leaf') + addConcreteWidget(leafNode, 'w') + const sourceNode = addNodeToHost(rootHost, 'source') + sourceNode.widgets = [ + createPromotedWidget('w', String(leafNode.id), 'w', nestedHost) + ] + + const result = resolveConcretePromotedWidget( + rootHost, + String(sourceNode.id), + 'w' + ) + + expect(result.status).toBe('resolved') + if (result.status !== 'resolved') return + + expect(result.resolved.node.id).toBe(leafNode.id) + expect(result.resolved.widget.name).toBe('w') + }) + + test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => { + const hosts = Array.from({ length: 102 }, (_, index) => + createHostNode(index + 1) + ) + const relayNodes = hosts.map((host, index) => + addNodeToHost(host, `relay-${index}`) + ) + + for (let index = 0; index < relayNodes.length - 1; index += 1) { + relayNodes[index].widgets = [ + createPromotedWidget( + `w-${index}`, + String(relayNodes[index + 1].id), + `w-${index + 1}`, + hosts[index + 1] + ) + ] + } + + addConcreteWidget( + relayNodes[relayNodes.length - 1], + `w-${relayNodes.length - 1}` + ) + + const result = resolveConcretePromotedWidget( + hosts[0], + String(relayNodes[0].id), + 'w-0' + ) + + expect(result).toEqual({ + status: 'failure', + failure: 'max-depth-exceeded' + }) + }) + + test('returns invalid-host for non-subgraph host node', () => { + const host = new LGraphNode('plain-host') + + const result = resolveConcretePromotedWidget(host, 'x', 'y') + + expect(result).toEqual({ + status: 'failure', + failure: 'invalid-host' + }) + }) + + test('returns missing-node when source node does not exist in host subgraph', () => { + const host = createHostNode(100) + + const result = resolveConcretePromotedWidget(host, 'missing-node', 'seed') + + expect(result).toEqual({ + status: 'failure', + failure: 'missing-node' + }) + }) + + test('returns missing-widget when source node exists but widget cannot be resolved', () => { + const host = createHostNode(100) + const sourceNode = addNodeToHost(host, 'source') + sourceNode.widgets = [] + + const result = resolveConcretePromotedWidget( + host, + String(sourceNode.id), + 'missing-widget' + ) + + expect(result).toEqual({ + status: 'failure', + failure: 'missing-widget' + }) + }) +}) diff --git a/src/core/graph/subgraph/resolveConcretePromotedWidget.ts b/src/core/graph/subgraph/resolveConcretePromotedWidget.ts new file mode 100644 index 0000000000..0a47d7bceb --- /dev/null +++ b/src/core/graph/subgraph/resolveConcretePromotedWidget.ts @@ -0,0 +1,102 @@ +import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes' +import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' +import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' +import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' + +type PromotedWidgetResolutionFailure = + | 'invalid-host' + | 'cycle' + | 'missing-node' + | 'missing-widget' + | 'max-depth-exceeded' + +type PromotedWidgetResolutionResult = + | { status: 'resolved'; resolved: ResolvedPromotedWidget } + | { status: 'failure'; failure: PromotedWidgetResolutionFailure } + +const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100 + +function traversePromotedWidgetChain( + hostNode: SubgraphNode, + nodeId: string, + widgetName: string +): PromotedWidgetResolutionResult { + const visited = new Set() + const hostUidByObject = new WeakMap() + let nextHostUid = 0 + let currentHost = hostNode + let currentNodeId = nodeId + let currentWidgetName = widgetName + + for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) { + let hostUid = hostUidByObject.get(currentHost) + if (hostUid === undefined) { + hostUid = nextHostUid + nextHostUid += 1 + hostUidByObject.set(currentHost, hostUid) + } + + const key = `${hostUid}:${currentNodeId}:${currentWidgetName}` + if (visited.has(key)) { + return { status: 'failure', failure: 'cycle' } + } + visited.add(key) + + const sourceNode = currentHost.subgraph.getNodeById(currentNodeId) + if (!sourceNode) { + return { status: 'failure', failure: 'missing-node' } + } + + const sourceWidget = sourceNode.widgets?.find( + (entry) => entry.name === currentWidgetName + ) + if (!sourceWidget) { + return { status: 'failure', failure: 'missing-widget' } + } + + if (!isPromotedWidgetView(sourceWidget)) { + return { + status: 'resolved', + resolved: { node: sourceNode, widget: sourceWidget } + } + } + + if (!sourceWidget.node?.isSubgraphNode()) { + return { status: 'failure', failure: 'missing-node' } + } + + currentHost = sourceWidget.node + currentNodeId = sourceWidget.sourceNodeId + currentWidgetName = sourceWidget.sourceWidgetName + } + + return { status: 'failure', failure: 'max-depth-exceeded' } +} + +export function resolvePromotedWidgetAtHost( + hostNode: SubgraphNode, + nodeId: string, + widgetName: string +): ResolvedPromotedWidget | undefined { + const node = hostNode.subgraph.getNodeById(nodeId) + if (!node) return undefined + + const widget = node.widgets?.find( + (entry: IBaseWidget) => entry.name === widgetName + ) + if (!widget) return undefined + + return { node, widget } +} + +export function resolveConcretePromotedWidget( + hostNode: LGraphNode, + nodeId: string, + widgetName: string +): PromotedWidgetResolutionResult { + if (!hostNode.isSubgraphNode()) { + return { status: 'failure', failure: 'invalid-host' } + } + return traversePromotedWidgetChain(hostNode, nodeId, widgetName) +} diff --git a/src/core/graph/subgraph/resolvePromotedWidgetSource.ts b/src/core/graph/subgraph/resolvePromotedWidgetSource.ts index 7b443ff475..2b9ff55e7e 100644 --- a/src/core/graph/subgraph/resolvePromotedWidgetSource.ts +++ b/src/core/graph/subgraph/resolvePromotedWidgetSource.ts @@ -1,29 +1,22 @@ +import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes' import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes' +import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget' import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets' -interface ResolvedPromotedWidgetSource { - node: LGraphNode - widget: IBaseWidget -} - export function resolvePromotedWidgetSource( hostNode: LGraphNode, widget: IBaseWidget -): ResolvedPromotedWidgetSource | undefined { +): ResolvedPromotedWidget | undefined { if (!isPromotedWidgetView(widget)) return undefined if (!hostNode.isSubgraphNode()) return undefined - const sourceNode = hostNode.subgraph.getNodeById(widget.sourceNodeId) - if (!sourceNode) return undefined - - const sourceWidget = sourceNode.widgets?.find( - (entry) => entry.name === widget.sourceWidgetName + const result = resolveConcretePromotedWidget( + hostNode, + widget.sourceNodeId, + widget.sourceWidgetName ) - if (!sourceWidget) return undefined + if (result.status === 'resolved') return result.resolved - return { - node: sourceNode, - widget: sourceWidget - } + return undefined } diff --git a/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts b/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts new file mode 100644 index 0000000000..450af13ac1 --- /dev/null +++ b/src/core/graph/subgraph/resolveSubgraphInputLink.test.ts @@ -0,0 +1,147 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphInputLink' +import { LGraphNode } from '@/lib/litegraph/src/litegraph' +import { + createTestSubgraph, + createTestSubgraphNode +} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers' +import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph' +import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode' + +vi.mock('@/renderer/core/canvas/canvasStore', () => ({ + useCanvasStore: () => ({}) +})) +vi.mock('@/stores/domWidgetStore', () => ({ + useDomWidgetStore: () => ({ widgetStates: new Map() }) +})) +vi.mock('@/services/litegraphService', () => ({ + useLitegraphService: () => ({ updatePreviews: () => ({}) }) +})) + +function createSubgraphSetup(inputName: string): { + subgraph: Subgraph + subgraphNode: SubgraphNode +} { + const subgraph = createTestSubgraph({ + inputs: [{ name: inputName, type: '*' }] + }) + const subgraphNode = createTestSubgraphNode(subgraph, { id: 1 }) + return { subgraph, subgraphNode } +} + +function addLinkedInteriorInput( + subgraph: Subgraph, + inputName: string, + linkedInputName: string, + widgetName: string +): { + node: LGraphNode + linkId: number +} { + const inputSlot = subgraph.inputNode.slots.find( + (slot) => slot.name === inputName + ) + if (!inputSlot) throw new Error(`Missing subgraph input slot: ${inputName}`) + + const node = new LGraphNode(`Interior-${linkedInputName}`) + const input = node.addInput(linkedInputName, '*') + node.addWidget('text', widgetName, '', () => undefined) + input.widget = { name: widgetName } + subgraph.add(node) + inputSlot.connect(input, node) + + if (input.link == null) + throw new Error(`Expected link to be created for input ${linkedInputName}`) + + return { node, linkId: input.link } +} + +beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + vi.clearAllMocks() +}) + +describe('resolveSubgraphInputLink', () => { + test('returns undefined for non-subgraph nodes', () => { + const node = new LGraphNode('plain-node') + + const result = resolveSubgraphInputLink(node, 'missing', () => 'resolved') + + expect(result).toBeUndefined() + }) + + test('returns undefined when input slot is missing', () => { + const { subgraphNode } = createSubgraphSetup('existing') + + const result = resolveSubgraphInputLink( + subgraphNode, + 'missing', + () => 'resolved' + ) + + expect(result).toBeUndefined() + }) + + test('skips stale links where inputNode.inputs is unavailable', () => { + const { subgraph, subgraphNode } = createSubgraphSetup('prompt') + addLinkedInteriorInput(subgraph, 'prompt', 'seed_input', 'seed') + const stale = addLinkedInteriorInput( + subgraph, + 'prompt', + 'stale_input', + 'stale' + ) + + const originalGetLink = subgraph.getLink.bind(subgraph) + vi.spyOn(subgraph, 'getLink').mockImplementation((linkId) => { + if (typeof linkId !== 'number') return originalGetLink(linkId) + if (linkId === stale.linkId) { + return { + resolve: () => ({ + inputNode: { + inputs: undefined, + getWidgetFromSlot: () => ({ name: 'ignored' }) + } + }) + } as unknown as ReturnType + } + + return originalGetLink(linkId) + }) + + const result = resolveSubgraphInputLink( + subgraphNode, + 'prompt', + ({ targetInput }) => targetInput.name + ) + + expect(result).toBe('seed_input') + }) + + test('caches getTargetWidget result within the same callback evaluation', () => { + const { subgraph, subgraphNode } = createSubgraphSetup('model') + const linked = addLinkedInteriorInput( + subgraph, + 'model', + 'model_input', + 'modelWidget' + ) + const getWidgetFromSlot = vi.spyOn(linked.node, 'getWidgetFromSlot') + + const result = resolveSubgraphInputLink( + subgraphNode, + 'model', + ({ getTargetWidget }) => { + expect(getTargetWidget()?.name).toBe('modelWidget') + expect(getTargetWidget()?.name).toBe('modelWidget') + return 'ok' + } + ) + + expect(result).toBe('ok') + expect(getWidgetFromSlot).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/core/graph/subgraph/resolveSubgraphInputLink.ts b/src/core/graph/subgraph/resolveSubgraphInputLink.ts new file mode 100644 index 0000000000..ab1f4f482f --- /dev/null +++ b/src/core/graph/subgraph/resolveSubgraphInputLink.ts @@ -0,0 +1,55 @@ +import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces' +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' + +type SubgraphInputLinkContext = { + inputNode: LGraphNode + targetInput: INodeInputSlot + getTargetWidget: () => ReturnType +} + +export function resolveSubgraphInputLink( + node: LGraphNode, + inputName: string, + resolve: (context: SubgraphInputLinkContext) => TResult | undefined +): TResult | undefined { + if (!node.isSubgraphNode()) return undefined + + const inputSlot = node.subgraph.inputNode.slots.find( + (slot) => slot.name === inputName + ) + if (!inputSlot) return undefined + + // Iterate from newest to oldest so the latest connection wins. + for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) { + const linkId = inputSlot.linkIds[index] + const link = node.subgraph.getLink(linkId) + if (!link) continue + + const { inputNode } = link.resolve(node.subgraph) + if (!inputNode) continue + if (!Array.isArray(inputNode.inputs)) continue + + const targetInput = inputNode.inputs.find((entry) => entry.link === linkId) + if (!targetInput) continue + + let cachedTargetWidget: + | ReturnType + | undefined + let hasCachedTargetWidget = false + + const resolved = resolve({ + inputNode, + targetInput, + getTargetWidget: () => { + if (!hasCachedTargetWidget) { + cachedTargetWidget = inputNode.getWidgetFromSlot(targetInput) + hasCachedTargetWidget = true + } + return cachedTargetWidget + } + }) + if (resolved !== undefined) return resolved + } + + return undefined +} diff --git a/src/core/graph/subgraph/resolveSubgraphInputTarget.ts b/src/core/graph/subgraph/resolveSubgraphInputTarget.ts new file mode 100644 index 0000000000..339933db0e --- /dev/null +++ b/src/core/graph/subgraph/resolveSubgraphInputTarget.ts @@ -0,0 +1,34 @@ +import type { LGraphNode } from '@/lib/litegraph/src/litegraph' + +import { resolveSubgraphInputLink } from './resolveSubgraphInputLink' + +type ResolvedSubgraphInputTarget = { + nodeId: string + widgetName: string +} + +export function resolveSubgraphInputTarget( + node: LGraphNode, + inputName: string +): ResolvedSubgraphInputTarget | undefined { + return resolveSubgraphInputLink( + node, + inputName, + ({ inputNode, targetInput, getTargetWidget }) => { + if (inputNode.isSubgraphNode()) { + return { + nodeId: String(inputNode.id), + widgetName: targetInput.name + } + } + + const targetWidget = getTargetWidget() + if (!targetWidget) return undefined + + return { + nodeId: String(inputNode.id), + widgetName: targetWidget.name + } + } + ) +} diff --git a/src/lib/litegraph/src/LGraph.test.ts b/src/lib/litegraph/src/LGraph.test.ts index 09a2f5c923..59780fc74a 100644 --- a/src/lib/litegraph/src/LGraph.test.ts +++ b/src/lib/litegraph/src/LGraph.test.ts @@ -634,4 +634,25 @@ describe('Subgraph Unpacking', () => { expect(unpackedTarget.inputs[0].link).not.toBeNull() expect(unpackedTarget.inputs[1].link).toBeNull() }) + + it('keeps subgraph definition when unpacking one instance while another remains', () => { + const rootGraph = new LGraph() + const subgraph = createSubgraphOnGraph(rootGraph) + + const firstInstance = createTestSubgraphNode(subgraph, { pos: [100, 100] }) + const secondInstance = createTestSubgraphNode(subgraph, { pos: [300, 100] }) + secondInstance.id = 2 + rootGraph.add(firstInstance) + rootGraph.add(secondInstance) + + rootGraph.unpackSubgraph(firstInstance) + + expect(rootGraph.subgraphs.has(subgraph.id)).toBe(true) + + const serialized = rootGraph.serialize() + const definitionIds = + serialized.definitions?.subgraphs?.map((definition) => definition.id) ?? + [] + expect(definitionIds).toContain(subgraph.id) + }) }) diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts index 7102978f97..d0cc890a07 100644 --- a/src/lib/litegraph/src/LGraph.ts +++ b/src/lib/litegraph/src/LGraph.ts @@ -1071,13 +1071,23 @@ export class LGraph } if (node.isSubgraphNode()) { - forEachNode(node.subgraph, (innerNode) => { - innerNode.onRemoved?.() - innerNode.graph?.onNodeRemoved?.(innerNode) - if (innerNode.isSubgraphNode()) - this.rootGraph.subgraphs.delete(innerNode.subgraph.id) - }) - this.rootGraph.subgraphs.delete(node.subgraph.id) + const allGraphs = [this.rootGraph, ...this.rootGraph.subgraphs.values()] + const hasRemainingReferences = allGraphs.some((graph) => + graph.nodes.some( + (candidate) => + candidate !== node && + candidate.isSubgraphNode() && + candidate.type === node.subgraph.id + ) + ) + + if (!hasRemainingReferences) { + forEachNode(node.subgraph, (innerNode) => { + innerNode.onRemoved?.() + innerNode.graph?.onNodeRemoved?.(innerNode) + }) + this.rootGraph.subgraphs.delete(node.subgraph.id) + } } // callback @@ -1869,6 +1879,7 @@ export class LGraph }) ) ) + return { subgraph, node: subgraphNode as SubgraphNode } } @@ -2055,7 +2066,6 @@ export class LGraph }) } this.remove(subgraphNode) - this.subgraphs.delete(subgraphNode.subgraph.id) // Deduplicate links by (oid, oslot, tid, tslot) to prevent repeated // disconnect/reconnect cycles on widget inputs that can shift slot indices. @@ -2342,7 +2352,6 @@ export class LGraph const usedSubgraphs = [...this._subgraphs.values()] .filter((subgraph) => usedSubgraphIds.has(subgraph.id)) .map((x) => x.asSerialisable()) - if (usedSubgraphs.length > 0) { data.definitions = { subgraphs: usedSubgraphs } } diff --git a/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.test.ts b/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.test.ts index ae56a571df..3e2902d95b 100644 --- a/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.test.ts +++ b/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.test.ts @@ -1,11 +1,18 @@ import { describe, expect, test } from 'vitest' import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager' -import type { SubgraphPromotionEntry } from '@/services/subgraphPseudoWidgetCache' -function makeView(entry: SubgraphPromotionEntry) { +type TestPromotionEntry = { + interiorNodeId: string + widgetName: string + viewKey?: string +} + +function makeView(entry: TestPromotionEntry) { + const baseKey = `${entry.interiorNodeId}:${entry.widgetName}` + return { - key: `${entry.interiorNodeId}:${entry.widgetName}` + key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey } } @@ -76,4 +83,46 @@ describe('PromotedWidgetViewManager', () => { expect(restored[0]).toBe(first[1]) expect(restored[1]).not.toBe(first[0]) }) + + test('keeps distinct views for same source widget when viewKeys differ', () => { + const manager = new PromotedWidgetViewManager<{ key: string }>() + + const views = manager.reconcile( + [ + { interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' }, + { interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' } + ], + makeView + ) + + expect(views).toHaveLength(2) + expect(views[0]).not.toBe(views[1]) + expect(views[0].key).toBe('1:widgetA:slotA') + expect(views[1].key).toBe('1:widgetA:slotB') + }) + + test('removeByViewKey removes only the targeted keyed view', () => { + const manager = new PromotedWidgetViewManager<{ key: string }>() + + const firstPass = manager.reconcile( + [ + { interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' }, + { interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' } + ], + makeView + ) + + manager.removeByViewKey('1', 'widgetA', 'slotA') + + const secondPass = manager.reconcile( + [ + { interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' }, + { interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' } + ], + makeView + ) + + expect(secondPass[0]).not.toBe(firstPass[0]) + expect(secondPass[1]).toBe(firstPass[1]) + }) }) diff --git a/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts b/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts index 9f04e26bfb..d8e7ebe3f3 100644 --- a/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts +++ b/src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts @@ -1,6 +1,7 @@ type PromotionEntry = { interiorNodeId: string widgetName: string + viewKey?: string } type CreateView = (entry: PromotionEntry) => TView @@ -14,20 +15,28 @@ type CreateView = (entry: PromotionEntry) => TView export class PromotedWidgetViewManager { private viewCache = new Map() private cachedViews: TView[] | null = null - private cachedEntriesRef: readonly PromotionEntry[] | null = null + private cachedEntryKeys: string[] | null = null reconcile( entries: readonly PromotionEntry[], createView: CreateView ): TView[] { - if (this.cachedViews && entries === this.cachedEntriesRef) + const entryKeys = entries.map((entry) => + this.makeKey(entry.interiorNodeId, entry.widgetName, entry.viewKey) + ) + + if (this.cachedViews && this.areEntryKeysEqual(entryKeys)) return this.cachedViews const views: TView[] = [] const seenKeys = new Set() for (const entry of entries) { - const key = this.makeKey(entry.interiorNodeId, entry.widgetName) + const key = this.makeKey( + entry.interiorNodeId, + entry.widgetName, + entry.viewKey + ) if (seenKeys.has(key)) continue seenKeys.add(key) @@ -47,16 +56,17 @@ export class PromotedWidgetViewManager { } this.cachedViews = views - this.cachedEntriesRef = entries + this.cachedEntryKeys = entryKeys return views } getOrCreate( interiorNodeId: string, widgetName: string, - createView: () => TView + createView: () => TView, + viewKey?: string ): TView { - const key = this.makeKey(interiorNodeId, widgetName) + const key = this.makeKey(interiorNodeId, widgetName, viewKey) const cached = this.viewCache.get(key) if (cached) return cached @@ -70,6 +80,15 @@ export class PromotedWidgetViewManager { this.invalidateMemoizedList() } + removeByViewKey( + interiorNodeId: string, + widgetName: string, + viewKey: string + ): void { + this.viewCache.delete(this.makeKey(interiorNodeId, widgetName, viewKey)) + this.invalidateMemoizedList() + } + clear(): void { this.viewCache.clear() this.invalidateMemoizedList() @@ -77,10 +96,25 @@ export class PromotedWidgetViewManager { invalidateMemoizedList(): void { this.cachedViews = null - this.cachedEntriesRef = null + this.cachedEntryKeys = null } - private makeKey(interiorNodeId: string, widgetName: string): string { - return `${interiorNodeId}:${widgetName}` + private areEntryKeysEqual(entryKeys: string[]): boolean { + if (!this.cachedEntryKeys) return false + if (this.cachedEntryKeys.length !== entryKeys.length) return false + + for (let index = 0; index < entryKeys.length; index += 1) { + if (this.cachedEntryKeys[index] !== entryKeys[index]) return false + } + return true + } + + private makeKey( + interiorNodeId: string, + widgetName: string, + viewKey?: string + ): string { + const baseKey = `${interiorNodeId}:${widgetName}` + return viewKey ? `${baseKey}:${viewKey}` : baseKey } } diff --git a/src/lib/litegraph/src/subgraph/SubgraphInput.ts b/src/lib/litegraph/src/subgraph/SubgraphInput.ts index 34cb2bd586..ee3d0f96d1 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphInput.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphInput.ts @@ -87,7 +87,9 @@ export class SubgraphInput extends SubgraphSlot { return } - this._widget ??= inputWidget + // Keep the widget reference in sync with the active upstream widget. + // Stale references can appear across nested promotion rebinds. + this._widget = inputWidget this.events.dispatch('input-connected', { input: slot, widget: inputWidget, @@ -208,6 +210,8 @@ export class SubgraphInput extends SubgraphSlot { override disconnect(): void { super.disconnect() + this._widget = undefined + this.events.dispatch('input-disconnected', { input: this }) } diff --git a/src/lib/litegraph/src/subgraph/SubgraphNode.ts b/src/lib/litegraph/src/subgraph/SubgraphNode.ts index 8dd3c95c6b..5f2066b024 100644 --- a/src/lib/litegraph/src/subgraph/SubgraphNode.ts +++ b/src/lib/litegraph/src/subgraph/SubgraphNode.ts @@ -34,6 +34,7 @@ import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView' +import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget' import { parseProxyWidgets } from '@/core/schemas/promotionSchema' import { useDomWidgetStore } from '@/stores/domWidgetStore' import { usePromotionStore } from '@/stores/promotionStore' @@ -48,6 +49,11 @@ const workflowSvg = new Image() workflowSvg.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E" +type LinkedPromotionEntry = { + inputName: string + interiorNodeId: string + widgetName: string +} // Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing // the SVG's internal stylesheet on every ctx.drawImage() call per frame. const workflowBitmapCache = createBitmapCache(workflowSvg, 32) @@ -78,21 +84,244 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { private _promotedViewManager = new PromotedWidgetViewManager() + /** + * Promotions buffered before this node is attached to a graph (`id === -1`). + * They are flushed in `_flushPendingPromotions()` from `_setWidget()` and + * `onAdded()`, so construction-time promotions require normal add-to-graph + * lifecycle to persist. + */ + private _pendingPromotions: Array<{ + interiorNodeId: string + widgetName: string + }> = [] // Declared as accessor via Object.defineProperty in constructor. // TypeScript doesn't allow overriding a property with get/set syntax, // so we use declare + defineProperty instead. declare widgets: IBaseWidget[] + private _resolveLinkedPromotionByInputName( + inputName: string + ): { interiorNodeId: string; widgetName: string } | undefined { + const resolvedTarget = resolveSubgraphInputTarget(this, inputName) + if (!resolvedTarget) return undefined + + return { + interiorNodeId: resolvedTarget.nodeId, + widgetName: resolvedTarget.widgetName + } + } + + private _getLinkedPromotionEntries(): LinkedPromotionEntry[] { + const linkedEntries: LinkedPromotionEntry[] = [] + + // TODO(pr9282): Optimization target. This path runs on widgets getter reads + // and resolves each input link chain eagerly. + for (const input of this.inputs) { + const resolved = this._resolveLinkedPromotionByInputName(input.name) + if (!resolved) continue + + linkedEntries.push({ inputName: input.name, ...resolved }) + } + + const seenEntryKeys = new Set() + const deduplicatedEntries = linkedEntries.filter((entry) => { + const entryKey = this._makePromotionViewKey( + entry.inputName, + entry.interiorNodeId, + entry.widgetName + ) + if (seenEntryKeys.has(entryKey)) return false + + seenEntryKeys.add(entryKey) + return true + }) + + return deduplicatedEntries + } + private _getPromotedViews(): PromotedWidgetView[] { const store = usePromotionStore() const entries = store.getPromotionsRef(this.rootGraph.id, this.id) + const linkedEntries = this._getLinkedPromotionEntries() + const { displayNameByViewKey, reconcileEntries } = + this._buildPromotionReconcileState(entries, linkedEntries) - return this._promotedViewManager.reconcile(entries, (entry) => - createPromotedWidgetView(this, entry.interiorNodeId, entry.widgetName) + return this._promotedViewManager.reconcile(reconcileEntries, (entry) => + createPromotedWidgetView( + this, + entry.interiorNodeId, + entry.widgetName, + entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined + ) ) } + private _syncPromotions(): void { + if (this.id === -1) return + + const store = usePromotionStore() + const entries = store.getPromotionsRef(this.rootGraph.id, this.id) + const linkedEntries = this._getLinkedPromotionEntries() + const { mergedEntries, shouldPersistLinkedOnly } = + this._buildPromotionPersistenceState(entries, linkedEntries) + if (!shouldPersistLinkedOnly) return + + const hasChanged = + mergedEntries.length !== entries.length || + mergedEntries.some( + (entry, index) => + entry.interiorNodeId !== entries[index]?.interiorNodeId || + entry.widgetName !== entries[index]?.widgetName + ) + if (!hasChanged) return + + store.setPromotions(this.rootGraph.id, this.id, mergedEntries) + } + + private _buildPromotionReconcileState( + entries: Array<{ interiorNodeId: string; widgetName: string }>, + linkedEntries: LinkedPromotionEntry[] + ): { + displayNameByViewKey: Map + reconcileEntries: Array<{ + interiorNodeId: string + widgetName: string + viewKey?: string + }> + } { + const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries( + entries, + linkedEntries + ) + const linkedReconcileEntries = + this._buildLinkedReconcileEntries(linkedEntries) + const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries) + + return { + displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries), + reconcileEntries: shouldPersistLinkedOnly + ? linkedReconcileEntries + : [...linkedReconcileEntries, ...fallbackStoredEntries] + } + } + + private _buildPromotionPersistenceState( + entries: Array<{ interiorNodeId: string; widgetName: string }>, + linkedEntries: LinkedPromotionEntry[] + ): { + mergedEntries: Array<{ interiorNodeId: string; widgetName: string }> + shouldPersistLinkedOnly: boolean + } { + const { linkedPromotionEntries, fallbackStoredEntries } = + this._collectLinkedAndFallbackEntries(entries, linkedEntries) + const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries) + + return { + mergedEntries: shouldPersistLinkedOnly + ? linkedPromotionEntries + : [...linkedPromotionEntries, ...fallbackStoredEntries], + shouldPersistLinkedOnly + } + } + + private _collectLinkedAndFallbackEntries( + entries: Array<{ interiorNodeId: string; widgetName: string }>, + linkedEntries: LinkedPromotionEntry[] + ): { + linkedPromotionEntries: Array<{ + interiorNodeId: string + widgetName: string + }> + fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }> + } { + const linkedPromotionEntries = this._toPromotionEntries(linkedEntries) + const fallbackStoredEntries = this._getFallbackStoredEntries( + entries, + linkedPromotionEntries + ) + + return { + linkedPromotionEntries, + fallbackStoredEntries + } + } + + private _shouldPersistLinkedOnly( + linkedEntries: LinkedPromotionEntry[] + ): boolean { + return this.inputs.length > 0 && linkedEntries.length === this.inputs.length + } + + private _toPromotionEntries( + linkedEntries: LinkedPromotionEntry[] + ): Array<{ interiorNodeId: string; widgetName: string }> { + return linkedEntries.map(({ interiorNodeId, widgetName }) => ({ + interiorNodeId, + widgetName + })) + } + + private _getFallbackStoredEntries( + entries: Array<{ interiorNodeId: string; widgetName: string }>, + linkedPromotionEntries: Array<{ + interiorNodeId: string + widgetName: string + }> + ): Array<{ interiorNodeId: string; widgetName: string }> { + const linkedKeys = new Set( + linkedPromotionEntries.map((entry) => + this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName) + ) + ) + return entries.filter( + (entry) => + !linkedKeys.has( + this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName) + ) + ) + } + + private _buildLinkedReconcileEntries( + linkedEntries: LinkedPromotionEntry[] + ): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> { + return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({ + interiorNodeId, + widgetName, + viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName) + })) + } + + private _buildDisplayNameByViewKey( + linkedEntries: LinkedPromotionEntry[] + ): Map { + return new Map( + linkedEntries.map((entry) => [ + this._makePromotionViewKey( + entry.inputName, + entry.interiorNodeId, + entry.widgetName + ), + entry.inputName + ]) + ) + } + + private _makePromotionEntryKey( + interiorNodeId: string, + widgetName: string + ): string { + return `${interiorNodeId}:${widgetName}` + } + + private _makePromotionViewKey( + inputName: string, + interiorNodeId: string, + widgetName: string + ): string { + return `${inputName}:${interiorNodeId}:${widgetName}` + } + private _resolveLegacyEntry( widgetName: string ): [string, string] | undefined { @@ -107,23 +336,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } // Fallback: find via subgraph input slot connection - const subgraphInput = this.subgraph.inputNode.slots.find( - (slot) => slot.name === widgetName - ) - if (!subgraphInput) return undefined + const resolvedTarget = resolveSubgraphInputTarget(this, widgetName) + if (!resolvedTarget) return undefined - for (const linkId of subgraphInput.linkIds) { - const link = this.subgraph.getLink(linkId) - if (!link) continue - const { inputNode } = link.resolve(this.subgraph) - if (!inputNode) continue - const targetInput = inputNode.inputs.find((inp) => inp.link === linkId) - if (!targetInput) continue - const w = inputNode.getWidgetFromSlot(targetInput) - if (w) return [String(inputNode.id), w.name] - } - - return undefined + return [resolvedTarget.nodeId, resolvedTarget.widgetName] } /** Manages lifecycle of all subgraph event listeners */ @@ -190,6 +406,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { if (widget) this.ensureWidgetRemoved(widget) this.removeInput(e.detail.index) + this._syncPromotions() this.setDirtyCanvas(true, true) }, { signal } @@ -309,6 +526,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { widgetLocator, e.detail.node ) + this._syncPromotions() }, { signal } ) @@ -325,6 +543,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { delete input.pos delete input.widget input._widget = undefined + this._syncPromotions() }, { signal } ) @@ -469,24 +688,68 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { break } } + + this._syncPromotions() } private _setWidget( subgraphInput: Readonly, input: INodeInputSlot, - _widget: Readonly, + interiorWidget: Readonly, inputWidget: IWidgetLocator | undefined, interiorNode: LGraphNode ) { - const nodeId = String(interiorNode.id) - const widgetName = _widget.name + this._flushPendingPromotions() - // Add to promotion store - usePromotionStore().promote(this.rootGraph.id, this.id, nodeId, widgetName) + const nodeId = String(interiorNode.id) + const widgetName = interiorWidget.name + + const previousView = input._widget + + if ( + previousView && + isPromotedWidgetView(previousView) && + (previousView.sourceNodeId !== nodeId || + previousView.sourceWidgetName !== widgetName) + ) { + usePromotionStore().demote( + this.rootGraph.id, + this.id, + previousView.sourceNodeId, + previousView.sourceWidgetName + ) + this._removePromotedView(previousView) + } + + if (this.id === -1) { + if ( + !this._pendingPromotions.some( + (entry) => + entry.interiorNodeId === nodeId && entry.widgetName === widgetName + ) + ) { + this._pendingPromotions.push({ + interiorNodeId: nodeId, + widgetName + }) + } + } else { + // Add to promotion store + usePromotionStore().promote( + this.rootGraph.id, + this.id, + nodeId, + widgetName + ) + } // Create/retrieve the view from cache - const view = this._promotedViewManager.getOrCreate(nodeId, widgetName, () => - createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name) + const view = this._promotedViewManager.getOrCreate( + nodeId, + widgetName, + () => + createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name), + this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName) ) // NOTE: This code creates linked chains of prototypes for passing across @@ -505,6 +768,26 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { }) } + private _flushPendingPromotions() { + if (this.id === -1 || this._pendingPromotions.length === 0) return + + for (const entry of this._pendingPromotions) { + usePromotionStore().promote( + this.rootGraph.id, + this.id, + entry.interiorNodeId, + entry.widgetName + ) + } + + this._pendingPromotions = [] + } + + override onAdded(_graph: LGraph): void { + this._flushPendingPromotions() + this._syncPromotions() + } + /** * Ensures the subgraph slot is in the params before adding the input as normal. * @param name The name of the input slot. @@ -650,6 +933,21 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { } } + private _removePromotedView(view: PromotedWidgetView): void { + this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName) + // Reconciled views can also be keyed by inputName-scoped view keys. + // Remove both key shapes to avoid stale cache entries across promote/rebind flows. + this._promotedViewManager.removeByViewKey( + view.sourceNodeId, + view.sourceWidgetName, + this._makePromotionViewKey( + view.name, + view.sourceNodeId, + view.sourceWidgetName + ) + ) + } + override removeWidget(widget: IBaseWidget): void { this.ensureWidgetRemoved(widget) } @@ -668,10 +966,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { widget.sourceNodeId, widget.sourceWidgetName ) - this._promotedViewManager.remove( - widget.sourceNodeId, - widget.sourceWidgetName - ) + this._removePromotedView(widget) } for (const input of this.inputs) { if (input._widget === widget) { @@ -683,6 +978,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph { widget, subgraphNode: this }) + + this._syncPromotions() } override onRemoved(): void { diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue index d843142c0b..0ceefd0c6e 100644 --- a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue +++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue @@ -184,6 +184,8 @@ const processedWidgets = computed((): ProcessedWidget[] => { for (const widget of widgets) { if (!shouldRenderAsVue(widget)) continue + const isPromotedView = !!widget.nodeId + const vueComponent = getComponent(widget.type) || (widget.isDOMWidget ? WidgetDOM : WidgetLegacy) @@ -191,9 +193,12 @@ const processedWidgets = computed((): ProcessedWidget[] => { const { slotMetadata } = widget // Get metadata from store (registered during BaseWidget.setNodeId) - const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId) + const bareWidgetId = stripGraphPrefix( + widget.storeNodeId ?? widget.nodeId ?? nodeId + ) + const storeWidgetName = widget.storeName ?? widget.name const widgetState = graphId - ? widgetValueStore.getWidget(graphId, bareWidgetId, widget.name) + ? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName) : undefined // Get value from store (falls back to undefined if not registered) @@ -205,7 +210,6 @@ const processedWidgets = computed((): ProcessedWidget[] => { ? { ...storeOptions, disabled: true } : storeOptions - const isPromotedView = !!widget.nodeId const borderStyle = graphId && !isPromotedView && diff --git a/src/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget.test.ts b/src/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget.test.ts index 17f0a87018..83bf815daa 100644 --- a/src/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/utils/resolvePromotedWidget.test.ts @@ -96,6 +96,38 @@ describe('resolveWidgetFromHostNode', () => { expect(resolved).toEqual({ node: innerNode, widget: innerWidget }) }) + it('resolves nested promoted widget chain to deepest interior widget', () => { + const innerWidget = createWidget('inner_text') + const innerNode = createHostNode([innerWidget]) + + const middleNode = createHostNode([], { + isSubgraphNode: true, + innerNodesById: { '100': innerNode } + }) + const middlePromotedWidget = { + ...createPromotedWidget('inner_text', '100', 'inner_text'), + node: middleNode + } as TestPromotedWidget & { node: LGraphNode } + middleNode.widgets = [middlePromotedWidget] + + const outerPromotedWidget = createPromotedWidget( + 'outer_text', + '42', + 'inner_text' + ) + const hostNode = createHostNode([outerPromotedWidget], { + isSubgraphNode: true, + innerNodesById: { '42': middleNode } + }) + + const resolved = resolveWidgetFromHostNode( + hostNode, + outerPromotedWidget.name + ) + + expect(resolved).toEqual({ node: innerNode, widget: innerWidget }) + }) + it('returns undefined when promoted interior node is missing', () => { const promotedWidget = createPromotedWidget( 'promoted_text',