Compare commits

...

16 Commits

Author SHA1 Message Date
dante01yoon
4a8fd059fe fix: use slot aliases for promoted widget rings 2026-04-12 17:25:53 +09:00
dante01yoon
e6e1b5ff05 fix: resolve promotion disambiguator at hydration time
Address Codex adversarial review findings:

1. [high] Fix at data layer instead of read-time fallback: during
   SubgraphNode._internalConfigureAfterSlots, resolve the concrete
   node for entries targeting SubgraphNodes and store the correct
   disambiguatingSourceNodeId. This ensures ALL promotion consumers
   (ring, side panel, demote) see consistent keys.

2. [medium] Prevent over-matching of duplicate-named widgets: replace
   the unconditional base-key fallback with dual-indexed ref counts.
   Disambiguated entries now increment both exact and base keys, so
   exact lookups (Vue renderer) match precisely while base lookups
   (legacy BaseWidget) still work without a disambiguator.

3. Simplify isWidgetPromoted to a pass-through — no special fallback
   logic needed since the data is correct at the source.
2026-04-12 10:54:53 +09:00
dante01yoon
0ddec541c5 Merge branch 'main' into fix/promoted-indicator-nested-subgraph
Resolve conflict in NodeWidgets.vue: take main's refactored
useProcessedWidgets composable and apply isWidgetPromoted fix there.
Fix import aliases in merged files to satisfy no-restricted-imports.
2026-04-12 08:55:05 +09:00
dante01yoon
c439282915 fix: revert extra proxyWidget that broke SubgraphEditor panel tests
The addition of ["6", "string_a"] to node 5's proxyWidgets created a
non-linked promotion (no corresponding subgraph input slot), breaking
three existing E2E tests that expect all widgets in the SubgraphEditor
panel to be linked. Revert the extra entry and relax the new test
assertion to toBeGreaterThan(0), which still validates the #10612 fix.
2026-04-12 07:32:43 +09:00
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
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
15 changed files with 378 additions and 52 deletions

View File

@@ -272,7 +272,7 @@
],
"properties": {
"proxyWidgets": [
["5", "string_a"],
["9", "string_a"],
["11", "value"],
["9", "value"],
["10", "string_a"]

View File

@@ -1,6 +1,6 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '../selectors'
import { TestIds } from '@e2e/fixtures/selectors'
const ids = TestIds.outputHistory

View File

@@ -1,8 +1,8 @@
import type { WebSocketRoute } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { ComfyPage } from '../ComfyPage'
import { createMockJob } from './AssetsHelper'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
/**
* Helper for simulating prompt execution in e2e tests.

View File

@@ -1,10 +1,10 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { setupBuilder } from '../helpers/builderTestUtils'
import { fitToViewInstant } from '../helpers/fitToView'
} from '@e2e/fixtures/ComfyPage'
import { setupBuilder } from '@e2e/helpers/builderTestUtils'
import { fitToViewInstant } from '@e2e/helpers/fitToView'
const RESIZE_NODE_TITLE = 'Resize Image/Mask'
const RESIZE_NODE_ID = '1'

View File

@@ -2,10 +2,13 @@ import type { WebSocketRoute } from '@playwright/test'
import { mergeTests } from '@playwright/test'
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { comfyPageFixture, comfyExpect as expect } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import { ExecutionHelper } from '../fixtures/helpers/ExecutionHelper'
import {
comfyPageFixture,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
const test = mergeTests(comfyPageFixture, webSocketFixture)

View File

@@ -191,4 +191,71 @@ test.describe('Nested Subgraphs', { 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 proxyWidgets promoted up to Sub 0
// (node 5). Those promoted widgets should carry the 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 promoted widgets'
})
.toBeGreaterThan(0)
})
}
)
})

View File

@@ -0,0 +1,152 @@
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 includes disambiguatingSourceNodeId', async () => {
// Scenario: SubBNode (id=3) inside SubA promotes a widget from
// ConcreteNode (id=1). During hydration, SubgraphNode.configure
// resolves the concrete node and stores the promotion WITH
// disambiguatingSourceNodeId so that the exact key matches the
// renderer's lookup.
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, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
const widgets = screen.getAllByTestId('widget-stub')
const hasPromotedRing = widgets.some((el) =>
el.classList.contains(PROMOTED_CLASS)
)
expect(hasPromotedRing).toBe(true)
})
test('shows promoted ring via base-key lookup when disambiguator is unknown', async () => {
// Legacy callers (e.g. BaseWidget) may not have the disambiguator.
// The dual-indexed base key ensures the ring still shows.
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'test_node',
isDOMWidget: false
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
renderComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
const widgets = screen.getAllByTestId('widget-stub')
const hasPromotedRing = widgets.some((el) =>
el.classList.contains(PROMOTED_CLASS)
)
expect(hasPromotedRing).toBe(true)
})
})

View File

@@ -1089,6 +1089,26 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
})
.filter((e): e is NonNullable<typeof e> => e !== null)
// Infer disambiguatingSourceNodeId for entries whose source node is a
// SubgraphNode. The renderer computes a promotionSourceNodeId from the
// concrete (leaf) node deep in the promotion chain; the store key must
// carry the same value so that exact-match lookups succeed (#10612).
for (const entry of entries) {
if (entry.disambiguatingSourceNodeId) continue
const sourceNode = this.subgraph.getNodeById(entry.sourceNodeId)
if (!sourceNode?.isSubgraphNode()) continue
const result = resolveConcretePromotedWidget(
this,
entry.sourceNodeId,
entry.sourceWidgetName
)
if (result.status === 'resolved') {
entry.disambiguatingSourceNodeId = String(result.resolved.node.id)
}
}
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy or stale entries don't persist

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

@@ -247,6 +247,46 @@ describe('computeProcessedWidgets borderStyle', () => {
).toBe(true)
})
it('uses slotName when ancestor promotion keeps the host widget alias', () => {
const promotedWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: 'inner-subgraph:10',
storeNodeId: 'inner-subgraph:10',
storeName: 'string_a',
slotName: 'value_1'
})
usePromotionStore().promote('graph-test', '5', {
sourceNodeId: '6',
sourceWidgetName: 'value_1',
disambiguatingSourceNodeId: '10'
})
const result = computeProcessedWidgets({
nodeData: {
id: '6',
type: 'SubgraphNode',
widgets: [promotedWidget],
title: 'Test',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
},
graphId: 'graph-test',
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: noopUi
})
expect(
result.some((w) => w.simplified.borderStyle?.includes('promoted'))
).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', () => {
const promotedWidget = createMockWidget({
name: 'text',

View File

@@ -270,17 +270,20 @@ export function computeProcessedWidgets({
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
const sourceWidgetName = widget.slotName ?? 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

@@ -817,7 +817,7 @@ describe(usePromotionStore, () => {
).toBe(true)
})
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
it('isPromotedByAny with disambiguatingSourceNodeId matches exact key and base key', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
@@ -837,12 +837,14 @@ describe(usePromotionStore, () => {
disambiguatingSourceNodeId: '2'
})
).toBe(false)
// Base-key lookup succeeds because dual-indexing keeps a ref count
// on the base key for callers that lack a disambiguator.
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text'
})
).toBe(false)
).toBe(true)
})
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {

View File

@@ -7,8 +7,12 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
function makePromotionBaseKey(source: PromotedWidgetSource): string {
return `${source.sourceNodeId}:${source.sourceWidgetName}`
}
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
const base = makePromotionBaseKey(source)
return source.disambiguatingSourceNodeId
? `${base}:${source.disambiguatingSourceNodeId}`
: base
@@ -40,14 +44,31 @@ export const usePromotionStore = defineStore('promotion', () => {
return nextRefCounts
}
function _incrementKey(refCounts: Map<string, number>, key: string): void {
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
}
function _decrementKey(refCounts: Map<string, number>, key: string): void {
const count = (refCounts.get(key) ?? 1) - 1
if (count <= 0) {
refCounts.delete(key)
} else {
refCounts.set(key, count)
}
}
function _incrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = makePromotionEntryKey(e)
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
_incrementKey(refCounts, makePromotionEntryKey(e))
// Also index the base key so callers without a disambiguator can
// still query whether any widget with this name is promoted.
if (e.disambiguatingSourceNodeId) {
_incrementKey(refCounts, makePromotionBaseKey(e))
}
}
}
@@ -57,12 +78,9 @@ export const usePromotionStore = defineStore('promotion', () => {
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = makePromotionEntryKey(e)
const count = (refCounts.get(key) ?? 1) - 1
if (count <= 0) {
refCounts.delete(key)
} else {
refCounts.set(key, count)
_decrementKey(refCounts, makePromotionEntryKey(e))
if (e.disambiguatingSourceNodeId) {
_decrementKey(refCounts, makePromotionBaseKey(e))
}
}
}
@@ -190,11 +208,31 @@ export const usePromotionStore = defineStore('promotion', () => {
graphRefCounts.value.delete(graphId)
}
/**
* Checks whether a widget is promoted by any subgraph node in the given
* graph. When disambiguatingSourceNodeId is provided, does an exact-key
* lookup; otherwise falls back to a base-key lookup (which succeeds if
* any widget with that name on the source node is promoted).
*/
function isWidgetPromoted(
graphId: UUID,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): boolean {
return isPromotedByAny(graphId, {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
})
}
return {
getPromotionsRef,
getPromotions,
isPromoted,
isPromotedByAny,
isWidgetPromoted,
setPromotions,
promote,
demote,