fix(subgraph): reactive price badge for ADR 0009 promoted widgets

Under ADR 0009 host-wins, PromotedWidgetView no longer mutates the
interior widget on edit. The subgraph wrapper's credits badge was still
computed against the inner LGraphNode's raw widget value and so stayed
frozen at the conversion-time price when the user changed a promoted
combo on the wrapper.

- getNodeDisplayPrice / buildJsonataContext accept an optional
  widgetOverrides map so callers can supply effective widget values
  without mutating the inner widget.
- For a SubgraphNode wrapping a single api node, updateSubgraphCredits
  now pushes a wrapper-aware badge getter that builds overrides from
  the wrapper's PromotedWidgetView host values and calls
  getNodeDisplayPrice with them. The legacy static / multi-node
  branches are preserved.
- usePartitionedBadges touches each PromotedWidgetView's host value and
  the inner api node's pricing inputs inside the wrapper's badge
  computed so a promoted edit invalidates the wrapper's badge.

priceBadge.spec.ts tag converted to the typed `{ tag: [...] }` form.

Amp-Thread-ID: https://ampcode.com/threads/T-019e5248-7986-77ae-a78b-41cd08c5af38
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
DrJKL
2026-05-22 18:33:53 -07:00
parent 238c2eef2d
commit 03aa8b462d
4 changed files with 151 additions and 39 deletions

View File

@@ -3,36 +3,40 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
test(
'Price badge displays on subgraphs',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
}
)

View File

@@ -237,12 +237,15 @@ const normalizeWidgetValue = (
const buildJsonataContext = (
node: LGraphNode,
rule: JsonataPricingRule
rule: JsonataPricingRule,
widgetOverrides?: ReadonlyMap<string, unknown>
): JsonataEvalContext => {
const widgets: Record<string, NormalizedWidgetValue> = {}
for (const dep of rule.depends_on.widgets) {
const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name)
widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type)
const raw = widgetOverrides?.has(dep.name)
? widgetOverrides.get(dep.name)
: node.widgets?.find((x: IBaseWidget) => x.name === dep.name)?.value
widgets[dep.name] = normalizeWidgetValue(raw, dep.type)
}
const inputs: Record<string, { connected: boolean }> = {}
@@ -552,7 +555,10 @@ export const useNodePricing = () => {
* - schedules async evaluation when needed
* - remains non-fatal on errors (returns safe fallback '')
*/
const getNodeDisplayPrice = (node: LGraphNode): string => {
const getNodeDisplayPrice = (
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): string => {
// Make this function reactive: when async evaluation completes, we bump pricingTick,
// which causes this getter to recompute in Vue render/computed contexts.
void pricingTick.value
@@ -565,7 +571,7 @@ export const useNodePricing = () => {
if (rule.engine !== 'jsonata') return ''
if (!rule._compiled) return ''
const ctx = buildJsonataContext(node, rule)
const ctx = buildJsonataContext(node, rule, widgetOverrides)
const sig = buildSignature(ctx, rule)
const cached = cache.get(node)

View File

@@ -1,6 +1,8 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
@@ -9,14 +11,25 @@ componentIconSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
export const usePriceBadge = () => {
const nodePricing = useNodePricing()
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
const newBadges = collectCreditsBadges(node.subgraph)
if (newBadges.length > 1) {
node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length))
} else {
node.badges.push(...newBadges)
const innerCreditsBadges = collectCreditsBadges(node.subgraph)
if (innerCreditsBadges.length > 1) {
node.badges.push(
getCreditsBadge('Partner Nodes x ' + innerCreditsBadges.length)
)
} else if (innerCreditsBadges.length === 1) {
const innerApiNodes = collectInnerApiNodes(node.subgraph)
// When a single inner api node is the price source, swap its static
// getter for a wrapper-aware one that resolves promoted widget values.
if (innerApiNodes.length === 1) {
node.badges.push(buildWrapperAwarePriceBadge(node, innerApiNodes[0]))
} else {
node.badges.push(...innerCreditsBadges)
}
}
const graph = node.graph
if (!graph) return
@@ -28,13 +41,14 @@ export const usePriceBadge = () => {
newValue: node.badges
})
}
function collectCreditsBadges(
graph: LGraph,
visited: Set<string> = new Set()
): (LGraphBadge | (() => LGraphBadge))[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const badges = []
const badges: (LGraphBadge | (() => LGraphBadge))[] = []
for (const node of graph.nodes) {
badges.push(
...(node.isSubgraphNode()
@@ -45,6 +59,57 @@ export const usePriceBadge = () => {
return badges
}
function collectInnerApiNodes(
graph: LGraph,
visited: Set<string> = new Set()
): LGraphNode[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const apiNodes: LGraphNode[] = []
for (const node of graph.nodes) {
if (node.isSubgraphNode()) {
apiNodes.push(...collectInnerApiNodes(node.subgraph, visited))
} else if (node.constructor?.nodeData?.api_node) {
apiNodes.push(node)
}
}
return apiNodes
}
/**
* Wrapper-aware price badge getter: resolves the inner node's price using
* the host-effective values of any promoted widgets on the wrapper, so the
* badge updates when a user edits the promoted control without leaking
* into the interior widget state (ADR 0009 host-wins).
*/
function buildWrapperAwarePriceBadge(
wrapper: LGraphNode,
innerNode: LGraphNode
): () => LGraphBadge {
return () =>
getCreditsBadge(
nodePricing.getNodeDisplayPrice(
innerNode,
collectPromotedOverrides(wrapper, innerNode)
)
)
}
function collectPromotedOverrides(
wrapper: LGraphNode,
innerNode: LGraphNode
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
}
return overrides
}
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {

View File

@@ -4,7 +4,7 @@ import { computed, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
@@ -69,6 +69,39 @@ export function trackNodePrice(node: TrackableNode) {
}
}
/**
* Register reactive deps on every contained api node's pricing inputs so the
* SubgraphNode wrapper's badge computed re-runs when an inner (e.g. promoted)
* widget value changes. Also tracks the wrapper's own promoted widget host
* values so user edits on the wrapper trigger re-evaluation (ADR 0009
* host-wins: promoted writes stay on the host and never leak interior).
*/
function trackSubgraphInnerNodePrices(wrapper: LGraphNode) {
if (!wrapper.isSubgraphNode()) return
// Touch each promoted widget's host value to register reactive deps.
for (const w of wrapper.widgets ?? []) void w.value
const visited = new Set<string>()
function walk(nodes: LGraphNode[]) {
for (const inner of nodes) {
if (inner.isSubgraphNode()) {
const id = String(inner.subgraph.id)
if (visited.has(id)) continue
visited.add(id)
walk(inner.subgraph.nodes)
continue
}
if (!inner.constructor?.nodeData?.api_node) continue
trackNodePrice({
id: inner.id,
type: inner.type ?? '',
inputs: inner.inputs
})
}
}
walk(wrapper.subgraph.nodes)
}
export function usePartitionedBadges(nodeData: VueNodeData) {
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
@@ -96,6 +129,10 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const unpartitionedBadges = computed<NodeBadgeProps[]>(() => {
if (nodeData?.id != null) {
const wrapper = app.canvas?.graph?.getNodeById(nodeData.id)
if (wrapper?.isSubgraphNode()) trackSubgraphInnerNodePrices(wrapper)
}
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {