Compare commits

...

14 Commits

Author SHA1 Message Date
dante01yoon
278a1eddb6 fix: correct nested subgraph promotion fixture for E2E test
- Fix node 6 proxyWidget ["5","string_a"] → ["9","string_a"]: node 5
  does not exist inside Sub 1 (only nodes 9, 10, 11), causing the entry
  to be filtered out and leaving only 3 widgets instead of 4.
- Add ["6","string_a"] to node 5 proxyWidgets so the isWidgetPromoted
  fallback lookup ("6:string_a" without disambiguator) matches, enabling
  the promoted ring on node 6's string_a widgets inside Sub 0.
2026-04-09 13:25:32 +09:00
dante01yoon
2330807b06 refactor: move isWidgetPromoted to promotionStore for shared use
Move the dual-key promotion check (with/without disambiguatingSourceNodeId)
from promotionUtils.ts into promotionStore as isWidgetPromoted(). This
avoids circular dependency issues when BaseWidget and domWidget need the
same nested subgraph fallback logic. All three consumers now use the
shared store method.
2026-04-08 20:21:30 +09:00
dante01yoon
894768a4b6 fix: use programmatic subgraph navigation in E2E test
The enterSubgraph utility clicks the enter button, but the canvas
z-999 overlay intercepts the click on root-level nodes. Revert to
programmatic navigation via page.evaluate.
2026-04-08 19:23:41 +09:00
dante01yoon
210384bbf7 refactor: address PR review feedback
- Extract isWidgetPromoted into promotionUtils.ts for shared use
- Use enterSubgraph utility in E2E test instead of manual page.evaluate
- Use expect.poll instead of comfyExpect().toPass() for single assertion
- Assert exact promoted ring count (4) instead of > 0
- Switch unit test from VTU mount to VTL render/screen
2026-04-08 17:10:25 +09:00
dante01yoon
288f52d033 Revert "refactor: extract PromotionEntryResolver from SubgraphNode"
This reverts commit 8402c2ae96.
2026-04-08 16:45:41 +09:00
dante
8402c2ae96 refactor: extract PromotionEntryResolver from SubgraphNode
Move promotion entry resolution logic (linked/fallback merging, alias
pruning, persistence decisions) into a standalone module to reduce
SubgraphNode method count and improve readability.

- Extract resolvePromotionEntries, buildLinkedReconcileEntries,
  buildDisplayNameByViewKey, makePromotionViewKey into
  PromotionEntryResolver.ts
- Remove 12 private methods (~280 lines) from SubgraphNode
- Eliminate _makePromotionEntryKey wrapper (use store export directly)
- Add 10 characterization tests pinning promotion entry resolution
  behavior before refactoring
2026-04-08 15:54:38 +09:00
Alexander Brown
7f0ab180b9 Merge branch 'main' into fix/promoted-indicator-nested-subgraph 2026-04-07 10:10:21 -07:00
Arthur R Longbottom
6cd3b59d5f fix: don't override loadGraphData viewport on cache miss (#10810)
## Summary

Fix regression from #10247 where template workflows (e.g. LTX2.3) loaded
with a broken viewport.

## Problem

`restoreViewport()` called `fitView()` on every cache miss via rAF. This
raced with `loadGraphData`'s own viewport restore (`extra.ds` for saved
workflows, or its own `fitView()` for templates at line 1266 of app.ts).
The second `fitView()` overwrote the correct viewport, causing templates
with subgraphs to display incorrectly.

## Fix

On cache miss, check if any nodes are already visible in the current
viewport before calling `fitView()`. If `loadGraphData` already
positioned things correctly, we don't override it. Only intervene when
the viewport is genuinely empty (first visit to a subgraph with no prior
cached state AND no loadGraphData restore).

## Review Focus

Single-file change in `subgraphNavigationStore.ts`. The visibility check
mirrors the same pattern used in `app.ts:1272-1281` where loadGraphData
itself checks for visible nodes.

## E2E Regression Test

The existing Playwright tests in
`browser_tests/tests/subgraphViewport.spec.ts` (added in #10247) already
cover viewport restoration after subgraph navigation. The specific
regression (template load viewport race) is not practically testable in
E2E because:
1. Template loading requires the backend's template API which returns
different templates per environment
2. The race condition depends on exact timing between `loadGraphData`'s
viewport restore and the rAF-deferred `restoreViewport` — Playwright
cannot reliably reproduce frame-level timing races
3. The fix is a guard condition (skip fitView if nodes visible) that
makes the behavior idempotent regardless of timing

## Alternative to #10790

This can replace the full revert in #10790 — it preserves the viewport
persistence feature while fixing the template regression.

Fixes regression from #10247
2026-04-07 10:01:54 -07:00
pythongosssss
0b83926c3e fix: Ensure zero uuid root graphs get assigned a valid id (#10825)
## Summary

Fixes an issue where handlers would be leaked causing Vue node rendering
to be corrupted (Vue nodes would not render) due to the
00000000-0000-0000-0000-000000000000 ID being used on the root graph.

## Changes

- **What**: 
- LGraph clear() skips store cleanup for the zero uuid, leaking handlers
that cause the node manager/handlers to be overwritten during operations
such as undo due to stale onNodeAdded hooks
- Ensures that graph configuration assigns a valid ID for root graphs

## Screenshots (if applicable)

Before fix, after doing ctrl+z after entering subgraph
<img width="1011" height="574" alt="image"
src="https://github.com/user-attachments/assets/1ff4692b-b961-4777-bf2d-9b981e311f91"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10825-fix-Ensure-zero-uuid-root-graphs-get-assigned-a-valid-id-3366d73d3650817d8603c71ffb5e5742)
by [Unito](https://www.unito.io)

---------

Co-authored-by: jaeone94 <89377375+jaeone94@users.noreply.github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-04-07 08:50:13 -07:00
dante01yoon
735d639d64 fix: use programmatic subgraph navigation to avoid click interception in E2E 2026-04-05 11:00:14 +09:00
dante01yoon
71a4098aa9 test: add E2E test for promoted indicator on 3-level nested subgraph (#10612) 2026-04-05 09:41:15 +09:00
dante01yoon
c601aab2c3 docs: add comment explaining promoted indicator fallback logic 2026-04-05 09:35:29 +09:00
dante01yoon
fd9e732b7f fix: also check base key in promoted widget indicator lookup
The isPromotedByAny check was only looking up the key with
disambiguatingSourceNodeId, missing promotions stored without one.
Now tries both with and without the disambiguating segment so
nested subgraph promotions are detected regardless of storage shape.

Fixes #10612
2026-04-05 00:13:39 +09:00
dante01yoon
8a923a2094 test: add failing test for promoted indicator on nested subgraphs
Reproduces #10612 where the promoted widget ring indicator is missing
on nested subgraphs due to a key mismatch in isPromotedByAny lookup.
2026-04-05 00:09:32 +09:00
14 changed files with 557 additions and 38 deletions

View File

@@ -0,0 +1,197 @@
{
"id": "00000000-0000-0000-0000-000000000000",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [627.5973510742188, 423.0972900390625],
"size": [144.15234375, 46],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 4,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [1],
"pos": {
"0": 447.9044189453125,
"1": 437.3822326660156
}
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": {
"0": 912.5973510742188,
"1": 436.0972900390625
}
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [554.8743286132812, 100.95539093017578],
"size": [270, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": null
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
},
{
"id": 2,
"type": "VAEEncode",
"pos": [685.1265869140625, 439.1734619140625],
"size": [140, 46],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "pixels",
"name": "pixels",
"type": "IMAGE",
"link": null
},
{
"localized_name": "vae",
"name": "vae",
"type": "VAE",
"link": null
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"links": [4]
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.8894351682943402,
"offset": [58.7671207025881, 137.7124650620126]
},
"frontendVersion": "1.24.1"
},
"version": 0.4
}

View File

@@ -55,7 +55,8 @@
["3", "string_a"],
["4", "value"],
["6", "value"],
["6", "value_1"]
["6", "value_1"],
["6", "string_a"]
]
},
"widgets_values": []
@@ -272,7 +273,7 @@
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["9", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]

View File

@@ -458,4 +458,71 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
})
}
)
/**
* Regression test for #10612: promoted widget indicator ring missing on
* nested subgraph nodes.
*
* Uses the 3-level nested fixture (subgraph-nested-promotion):
* Root → Sub 0 (node 5) → Sub 1 (node 6) → Sub 2 (node 9)
*
* Node 6 (Sub 1) has proxyWidgets promoting widgets from inner nodes,
* and those promotions are also promoted up to node 5 (Sub 0). When
* navigating into Sub 0, node 6 should show the promoted ring on its
* widgets.
*/
test.describe(
'Promoted indicator on 3-level nested subgraphs (#10612)',
{ tag: ['@widget'] },
() => {
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate SubgraphNode shows promoted ring inside parent subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// At root level, node 5 (Sub 0) is the outermost SubgraphNode.
// Its widgets are not promoted further, so no ring expected.
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await comfyExpect(outerNode).toBeVisible()
const outerRings = outerNode.locator(`.${PROMOTED_BORDER_CLASS}`)
await comfyExpect(outerRings).toHaveCount(0)
// Navigate programmatically — the enter-subgraph button on
// node 5 is obscured by the canvas z-999 overlay at root level.
await comfyPage.page.evaluate(() => {
const node = window.app!.graph!.getNodeById('5')
if (node?.isSubgraphNode()) {
window.app!.canvas.setGraph(node.subgraph)
}
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 6 (Sub 1) has 4 proxyWidgets promoted up to Sub 0
// (node 5). Each should carry the promoted ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('6')
await comfyExpect(intermediateNode).toBeVisible()
const intermediateRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect
.poll(() => intermediateRings.count(), {
timeout: 5000,
message:
'Node 6 (Sub 1) should show promoted rings for all 4 proxyWidgets'
})
.toBe(4)
})
}
)
})

View File

@@ -0,0 +1,49 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
test.describe(
'Zero UUID workflow: subgraph undo rendering',
{ tag: ['@workflow', '@subgraph'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
test.setTimeout(30000) // Extend timeout as we need to reload the page an additional time
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.page.reload() // Reload page as we need to enter in Vue mode
await comfyPage.page.waitForFunction(() => !!window.app?.graph)
})
test('Undo after subgraph enter/exit renders all nodes when workflow starts with zero UUID', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/basic-subgraph-zero-uuid'
)
await comfyPage.vueNodes.waitForNodes()
const assertInSubgraph = async (inSubgraph: boolean) => {
await expect
.poll(() => comfyPage.subgraph.isInSubgraph())
.toBe(inSubgraph)
}
// Root graph has 1 subgraph node, rendered in the DOM
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
await comfyPage.vueNodes.enterSubgraph()
await assertInSubgraph(true)
await comfyPage.subgraph.exitViaBreadcrumb()
await assertInSubgraph(false)
await comfyPage.canvas.focus()
await comfyPage.keyboard.undo()
await comfyPage.nextFrame()
// After undo, the subgraph node is still visible and rendered
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(1)
})
}
)

View File

@@ -0,0 +1,133 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { describe, expect, test, vi } from 'vitest'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { usePromotionStore } from '@/stores/promotionStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: {
id: 'graph-test'
}
}
}
})
}))
const PROMOTED_CLASS = 'ring-component-node-widget-promoted'
const WidgetStub = defineComponent({
props: { widget: { type: Object, default: undefined } },
setup(props) {
return () =>
h('div', {
'data-testid': 'widget-stub',
class: props.widget?.borderStyle
})
}
})
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
() => ({
getComponent: () => WidgetStub,
shouldExpand: () => false,
shouldRenderAsVue: () => true
})
)
function createMockWidget(
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData {
return {
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
}
}
function createMockNodeData(
nodeType: string,
widgets: SafeWidgetData[],
id: string
): VueNodeData {
return {
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
}
}
function renderComponent(nodeData: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: { nodeData },
global: {
plugins: [pinia],
stubs: { InputSlot: true },
mocks: { $t: (key: string) => key }
}
})
}
describe('promoted widget indicator on nested subgraphs', () => {
test('shows promoted ring when promotion was stored without disambiguatingSourceNodeId', async () => {
// Scenario: SubBNode (id=3) inside SubA promotes a widget from
// ConcreteNode (id=1). The promotion at the SubA level is stored
// WITHOUT disambiguatingSourceNodeId because ConcreteNode is not
// itself a SubgraphNode.
//
// The widget rendered on SubBNode has storeName and storeNodeId set
// (because it's a promoted widget), so NodeWidgets.vue would normally
// compute a disambiguatingSourceNodeId from the storeNodeId.
// This causes a key mismatch: lookup key "3:text:1" vs stored "3:text".
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
renderComponent(nodeData, () => {
// Store promotion WITHOUT disambiguatingSourceNodeId, as would
// happen for a first-level nested promotion where the inner node
// is not itself a SubgraphNode.
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text'
})
})
const widgets = screen.getAllByTestId('widget-stub')
const hasPromotedRing = widgets.some((el) =>
el.classList.contains(PROMOTED_CLASS)
)
expect(hasPromotedRing).toBe(true)
})
})

View File

@@ -12,6 +12,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
@@ -1005,3 +1006,25 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
expect(nodeIdSet(graph, SUBGRAPH_B)).toEqual(new Set([20, 21, 22]))
})
})
describe('Zero UUID handling in configure', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
})
it('rejects zeroUuid for root graphs and assigns a new ID', () => {
const graph = new LGraph()
const data = graph.serialize()
data.id = zeroUuid
graph.configure(data)
expect(graph.id).not.toBe(zeroUuid)
})
it('preserves zeroUuid for subgraphs', () => {
const graph = new LGraph()
const subgraphData = { ...createTestSubgraphData(), id: zeroUuid }
const subgraph = graph.createSubgraph(subgraphData)
subgraph.configure(subgraphData)
expect(subgraph.id).toBe(zeroUuid)
})
})

View File

@@ -2480,8 +2480,8 @@ export class LGraph
protected _configureBase(data: ISerialisedGraph | SerialisableGraph): void {
const { id, extra } = data
// Create a new graph ID if none is provided
if (id) {
// Create a new graph ID if none is provided or the zero UUID is used on the root graph
if (id && !(this.isRootGraph && id === zeroUuid)) {
this.id = id
} else if (this.id === zeroUuid) {
this.id = createUuidv4()

View File

@@ -211,10 +211,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
usePromotionStore().isWidgetPromoted(
graphId,
String(this.node.id),
this.name
)
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
return this.advanced

View File

@@ -390,17 +390,20 @@ const processedWidgets = computed((): ProcessedWidget[] => {
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
const sourceWidgetName = widget.storeName ?? widget.name
const isPromoted =
graphId &&
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
promotionStore.isWidgetPromoted(
graphId,
hostNodeId,
sourceWidgetName,
promotionSourceNodeId
)
const borderStyle = isPromoted
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId

View File

@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ComponentWidgetImpl, DOMWidgetImpl } from '@/scripts/domWidget'
const isPromotedByAnyMock = vi.hoisted(() => vi.fn())
const isWidgetPromotedMock = vi.hoisted(() => vi.fn())
// Mock dependencies
vi.mock('@/stores/domWidgetStore', () => ({
@@ -14,7 +14,7 @@ vi.mock('@/stores/domWidgetStore', () => ({
vi.mock('@/stores/promotionStore', () => ({
usePromotionStore: () => ({
isPromotedByAny: isPromotedByAnyMock
isWidgetPromoted: isWidgetPromotedMock
})
}))
@@ -120,7 +120,7 @@ describe('DOMWidget draw promotion behavior', () => {
})
test('draws promoted outline for visible promoted widgets', () => {
isPromotedByAnyMock.mockReturnValue(true)
isWidgetPromotedMock.mockReturnValue(true)
const node = new LGraphNode('test-node')
const rootGraph = { id: 'root-graph-id' }
@@ -138,16 +138,17 @@ describe('DOMWidget draw promotion behavior', () => {
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
sourceNodeId: '-1',
sourceWidgetName: 'seed'
})
expect(isWidgetPromotedMock).toHaveBeenCalledWith(
'root-graph-id',
'-1',
'seed'
)
expect(ctx.strokeRect).toHaveBeenCalledOnce()
expect(onDraw).toHaveBeenCalledWith(widget)
})
test('does not draw promoted outline when widget is not promoted', () => {
isPromotedByAnyMock.mockReturnValue(false)
isWidgetPromotedMock.mockReturnValue(false)
const node = new LGraphNode('test-node')
const rootGraph = { id: 'root-graph-id' }
@@ -187,7 +188,7 @@ describe('DOMWidget draw promotion behavior', () => {
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).not.toHaveBeenCalled()
expect(isWidgetPromotedMock).not.toHaveBeenCalled()
expect(ctx.strokeRect).not.toHaveBeenCalled()
expect(onDraw).toHaveBeenCalledWith(widget)
})

View File

@@ -125,8 +125,6 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
declare readonly name: string
declare readonly options: DOMWidgetOptions<V>
declare callback?: (value: V) => void
readonly promotionStore = usePromotionStore()
readonly id: string
constructor(obj: {
@@ -190,10 +188,11 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
const graphId = this.node.graph?.rootGraph.id
const isPromoted =
graphId &&
this.promotionStore.isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
usePromotionStore().isWidgetPromoted(
graphId,
String(this.node.id),
this.name
)
if (!isPromoted) {
this.options.onDraw?.(this)
return

View File

@@ -190,11 +190,35 @@ export const usePromotionStore = defineStore('promotion', () => {
graphRefCounts.value.delete(graphId)
}
/**
* Checks whether a widget is promoted by any subgraph node in the given
* graph. Handles nested subgraph promotions where the stored key may omit
* the disambiguatingSourceNodeId — checks both key shapes (#10612).
*/
function isWidgetPromoted(
graphId: UUID,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): boolean {
if (
disambiguatingSourceNodeId &&
isPromotedByAny(graphId, {
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
})
)
return true
return isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName })
}
return {
getPromotionsRef,
getPromotions,
isPromoted,
isPromotedByAny,
isWidgetPromoted,
setPromotions,
promote,
demote,

View File

@@ -12,6 +12,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
import { anyItemOverlapsRect } from '@/utils/mathUtil'
import { isNonNullish, isSubgraph } from '@/utils/typeGuardUtil'
export const VIEWPORT_CACHE_MAX_SIZE = 32
@@ -138,11 +139,19 @@ export const useSubgraphNavigationStore = defineStore(
return
}
// Cache miss — fit to content after the canvas has the new graph.
// rAF fires after layout + paint, when nodes are positioned.
const expectedGraphId = graphId
// Cache miss — fit to content only if no nodes are currently visible.
// loadGraphData may have already restored extra.ds or called fitView
// for templates, so only intervene when the viewport is truly empty.
requestAnimationFrame(() => {
if (getActiveGraphId() !== expectedGraphId) return
if (getActiveGraphId() !== graphId) return
if (!canvas.graph) return
const nodes = canvas.graph.nodes
if (!nodes?.length) return
canvas.ds.computeVisibleArea(canvas.viewport)
if (anyItemOverlapsRect(nodes, canvas.ds.visible_area)) return
useLitegraphService().fitView()
})
}

View File

@@ -24,8 +24,11 @@ vi.mock('@/scripts/app', () => {
scale: 1,
offset: [0, 0],
state: { scale: 1, offset: [0, 0] },
fitToBounds: vi.fn()
fitToBounds: vi.fn(),
visible_area: [0, 0, 1000, 1000],
computeVisibleArea: vi.fn()
},
viewport: [0, 0, 1000, 1000],
setDirty: mockSetDirty,
get empty() {
return true
@@ -184,6 +187,11 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
// Ensure no cached entry
store.viewportCache.delete(':root')
// Add a node outside the visible area so anyItemOverlapsRect returns false
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
mockGraph.nodes = [{ pos: [9999, 9999], size: [100, 100] }]
mockGraph._nodes = mockGraph.nodes
// Use the root graph ID so the stale-guard passes
store.restoreViewport('root')
@@ -194,6 +202,10 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
rafCallbacks[0](performance.now())
expect(mockFitView).toHaveBeenCalledOnce()
// Cleanup
mockGraph.nodes = []
mockGraph._nodes = []
})
it('skips fitView if active graph changed before rAF fires', () => {