LGraph.clear() and configure() reassign the root graph id, so the badge system now resolves it on every recompute instead of capturing it at start; a captured id stranded the system on a dead bucket after Clear Workflow, dropping every badge on nodes added afterwards. LGraph.add registers the node in the badge store only after onNodeAdded: the store write wakes the system watcher and queues Vue's flush, which must not overtake the deferred paste scan that surfaces missing-model errors before selection-scoped tabs recalculate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.3 KiB
Node Badge Store
Date: 2026-07-05 (updated 2026-07-06)
Status: Implemented — slice A (store + system) and slice B (consumer
cutover, resolved decisions below) shipped as
src/stores/nodeBadgeStore.ts, src/systems/badgeSystem.ts,
src/lib/litegraph/src/nodeBadgeDraw.ts. Follow-up to the
link topology store,
reroute chain store, and the
node data store draft
Design record for extracting node badges off LGraphNode instances into
a dedicated store per ADR 0008,
going straight to plain-data components — no interim closure storage.
Vocabulary: domain glossary § Badges.
Current state (what this replaces)
LGraphNode.badges: (LGraphBadge | (() => LGraphBadge))[] mixes three
things: a core badge closure (id / lifecycle / source, re-derived
independently by the Vue renderer, which skips it positionally via
slice(1)), credits badge closures (creditsBadgeGetter,
buildWrapperAwarePriceBadge — hand-rolled reactive computeds the
legacy canvas polls per frame), and a public push surface for
extensions. Badge changes are announced by a single manual
node:property:changed trigger in usePriceBadge; plain pushes are
invisible. Credits badges are classified by icon identity
(icon.image === componentIconSvg). Badges never serialize (verified:
no badges key in serialize()/configure).
Every closure is a reactive computed feeding an unreactive array, and the Vue renderer has already re-implemented all of their reactivity as store-tracked dependencies. The design deletes the closures and makes the store rows the single truth both renderers read.
Decision 1: Plain BadgeData rows, no interim shape
The store holds only plain data (ADR 0008 component rule):
BadgeData {
kind: 'core' | 'credits' | 'extension'
text: string
fgColor?: string
bgColor?: string
iconKey?: string // resolved via a small icon registry ('credits' → SVG)
}
No onClick (no producer exists; LGraphButton/title_buttons are a
separate surface and out of scope). No Image objects — icons are
referenced by key. A commandId field can be added if a clickable badge
requirement ever materialises.
Rejected: an interim BadgeEntry { kind, source: LGraphBadge | thunk }
phase. The public element type of node.badges breaks either way; one
break that lands on the end-state shape beats two.
Decision 2: Store shape and keying
nodeBadgeStore: root-graph-scoped buckets (rootGraph.id) keyed by
NodeId, each holding an ordered reactive BadgeData[]. Register /
unregister / unregister-all trio at the LGraph.add / LGraph.remove /
clear() chokepoints, identity-checked deletes — the shipped store
conventions.
Rows are partitioned by kind at read time; the positional slice(1)
convention and icon-identity credits detection are deleted. Display
order is kind order (core, credits, extension), not insertion order.
Decision 3: A reactive BadgeSystem writes the rows
One system module owns the recomputation: per registered node, an
effectScope runs a pure computeBadges(sources) → BadgeData[]
function inside a thin watch shell and writes the node's rows. Sources
are the existing stores: nodeDefStore, settingStore,
colorPaletteStore, useNodePricing revision refs, widgetValueStore,
linkStore input connectivity. The pure function is the future
command-pipeline phase body (ADR 0003 systems ADR); only the scheduler
shell changes when that lands.
useNodeBadge / usePriceBadge stop pushing closures; their derivation
logic moves into computeBadges. The manual 'badges'
node:property:changed trigger, the badges case in
useGraphNodeManager, and VueNodeData.badges are deleted.
usePartitionedBadges collapses to a store query partitioned by kind;
its manual dependency-touching (trackNodePrice,
trackSubgraphInnerNodePrices) moves inside the system.
Decision 4: Core badges are system-written rows too
Core (#id / lifecycle / source) badges are materialized by the system like every other kind, so both renderers consume one uniform row set. This kills the current dual derivation (legacy closure + Vue-side re-derivation). Materialized-by-system is the ECS write path, not a mirror: no other component stores this projection.
Decision 5: Legacy canvas consumes rows via a draw cache
drawBadges renders from BadgeData, constructing and caching
LGraphBadge draw objects keyed by row content (the Reroute id-badge
pattern, memoized). _boundingRect hit-test state stays renderer-side.
Frame-budget parity per ADR 0008's render mitigations applies.
Implementation notes (slice A)
- Registration is bucket-key presence: the
LGraph.add/remove/clearchokepoints call the import-light store trio (registerNode/unregisterNode/clearGraph), and the system watchesregisteredNodeIdsto attach/detach per-node effect scopes. Litegraph never imports the system, so the pricing/nodeDef dependency graph (which runtime-imports the litegraph barrel) stays acyclic. The system takesresolveGraphId/resolveNodeseams and is bootstrapped once at the app layer by theComfy.NodeBadgeextension (useNodeBadge). The graph id is resolved live on every recompute becauseLGraph.clear()andconfigure()reassign the root graph's id; a captured id strands the system on a dead bucket. Row writes are refused for unregistered nodes so a late effect flush cannot resurrect a bucket key the chokepoints deleted.LGraph.addregisters the node only afteronNodeAdded: the store write wakes the system's watcher and queues Vue's flush microtask, which must not overtake the paste-scan microtaskuseErrorClearingHooksenqueues fromonNodeAdded. - The shell still reads
node.constructor.nodeDataandnode.inputs(untracked instance state) to map pricing input names to slot indices — parity with the legacy closures. Those reads become store lookups when slot data is store-backed (node data store draft). - Core rows are fine-grained — one row per part, tagged
part: 'lifecycle' | 'id' | 'source'and carrying the raw projected text — with one visibility rule (badgeTextVisible, the legacy semantics:HideBuiltInrespected for every part). Each renderer owns presentation: the legacy draw cache joins parts in id, lifecycle, source order and truncates; the Vue partition trims lifecycle brackets, and replaces a built-in node's source row with its Comfy-logo chip. Known unification effect: the Vue renderer gainsHideBuiltInhandling for id and lifecycle badges on built-in nodes. - A credits row is emitted only when the display price label is non-empty; async pricing fills it via the per-node revision ref.
Implementation notes (slice B)
- Wrapper
SubgraphNodecredits rows are aggregated by the system over the inner (recursively collected, non-wrapper) api nodes — the same count basis as the old unconditional per-api-node badges: several collapse toPartner Nodes x N(localized), exactly one shows its price with the wrapper's promoted widget values overriding the inner node's own. The aggregation tracks the inner nodes' pricing revisions, the registered-node set, and a structure revision.useNodePricingcaches labels per signature so a leaf's own read and its wrapper's override read do not evict each other and re-schedule forever. - Legacy
drawBadgesrenders the store rows through a per-node memoizedLGraphBadgecache (decision 5);iconKeyresolves through a generic litegraphbadgeIconRegistry, with thecreditsicon registered by the system module.
Resolved decisions (2026-07-06 interview)
- Legacy
node.badgessurface — kept as a raw extension-only push surface. Directive: delete unless actively used. A GitHub-wide scan found three custom-node packs actively writing it (ComfyUI-Wildcard-Pipeline pushes and splicesLGraphBadgeinstances; ComfyUI-Enhancement-Utils and ComfyUI-XENodes push per-frame getter thunks for ticking execution-time badges), so the array stays, is no longer written by core/credits code, and both renderers append it after the store rows — thunks still evaluate per frame in the legacy canvas. The earlier "zero third-party writers" scan was wrong. node.badgePosition— deleted, along with theBadgePositionenum. Zero uses outside this repo; the legacy canvas now always renders badges top-right (the only surviving behaviour).- Subgraph credits aggregation triggers — event-bumped revision.
litegraph:set-graph,subgraph-converted, andafterConfigureGraphbumpbumpSubgraphCreditsRevision(); swap to reactiveSubgraphStructuredependencies when that state is store-backed. Aggregation behaviour itself is preserved (explicit user requirement).
Scope and sequencing
Slices: (A) store + BadgeData + system for core and credits +
chokepoint registration; (B) consumer cutover — Vue partition query,
legacy draw cache, trigger/VueNodeData.badges deletions, legacy
surface shim; extension-facing deprecation notes. Independent of the
pending nodeDataStore extraction and lands before it, shrinking its
Decision 4/6 scope (one less VueNodeData field, one less property
handler). PartnerNodesList's find(isCreditsBadge) migrates to a
store query by kind.