From 03aa8b462daae1ac8decb7dacce34f8eed9628cf Mon Sep 17 00:00:00 2001 From: DrJKL Date: Fri, 22 May 2026 18:33:53 -0700 Subject: [PATCH] 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 --- browser_tests/tests/priceBadge.spec.ts | 58 +++++++------- src/composables/node/useNodePricing.ts | 16 ++-- src/composables/node/usePriceBadge.ts | 77 +++++++++++++++++-- .../composables/usePartitionedBadges.ts | 39 +++++++++- 4 files changed, 151 insertions(+), 39 deletions(-) diff --git a/browser_tests/tests/priceBadge.spec.ts b/browser_tests/tests/priceBadge.spec.ts index 2ba37cf1fe..70a3447f95 100644 --- a/browser_tests/tests/priceBadge.spec.ts +++ b/browser_tests/tests/priceBadge.spec.ts @@ -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) + ) + } +) diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index 5d5ed7668e..f593696191 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -237,12 +237,15 @@ const normalizeWidgetValue = ( const buildJsonataContext = ( node: LGraphNode, - rule: JsonataPricingRule + rule: JsonataPricingRule, + widgetOverrides?: ReadonlyMap ): JsonataEvalContext => { const widgets: Record = {} 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 = {} @@ -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 => { // 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) diff --git a/src/composables/node/usePriceBadge.ts b/src/composables/node/usePriceBadge.ts index 5febb21b16..3d9130b6a6 100644 --- a/src/composables/node/usePriceBadge.ts +++ b/src/composables/node/usePriceBadge.ts @@ -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 = 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 = 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 { + const overrides = new Map() + 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 | (() => Partial) ): boolean { diff --git a/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts b/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts index 81621da66f..1e2bb9054a 100644 --- a/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts +++ b/src/renderer/extensions/vueNodes/composables/usePartitionedBadges.ts @@ -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() + 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(() => { + 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) {