Compare commits

...

106 Commits

Author SHA1 Message Date
DrJKL
99520265ac refactor!: delete the input.link runtime mirror
The linkStore is the single source for input-slot connectivity.
Readers use node.isInputConnected / node.getInputLink or the slotLinks
input helpers; NodeInputSlot.link is a deprecation-telemetry accessor:
reads warn and return the store-derived id, writes warn naming
connect()/disconnectInput() and are ignored. Serialization derives
inputs[].link from the store; wire format unchanged.

Store re-keying is authoritative: moving a registered link evicts any
incumbent under the new key, so slot permutations cannot drop links;
first-wins placement remains only for load-time registration, which
duplicate-link dedup depends on. dynamicWidgets rebuilds capture one
at-rest slot-to-link snapshot and reconcile once at the end.
fixLinkInputSlots is replaced by realignInputLinkSlots inside
LGraph.configure, reading the effective (possibly deduplicated-clone)
node data. disconnectOutput's duplicated branches collapse into one
per-link loop that removes the store entry before onConnectionsChange
fires. 'link' in slot discriminators are deleted in favor of direction
threading; _setConcreteSlots writes wrappers back into node
inputs/outputs so identity-based lookups hold; addInput/addOutput drop
legacy link/links keys from extra_info. First-party code no longer
touches either deprecated mirror.
2026-07-08 11:04:05 -07:00
DrJKL
f4ceedb787 refactor!: delete the output.links runtime mirror
The linkStore is the single source for output-side connectivity. The
NodeOutputSlot.links field and all nine write sites are gone; a
read-only prototype getter derives the array from the store and fires
warnDeprecated as migration telemetry for extensions (no setter, so
writes throw). INodeOutputSlot keeps links as deprecated readonly so
'links in slot' discriminants still compile. Wire format is unchanged:
serialization derives outputs[].links from the store, and serialized
operators (linkFixer serialized branch, migrateReroute, unpackSubgraph
strip) are untouched; linkFixer now completes a missing input mirror
instead of deleting a store-registered link.

Also fixes createTestSubgraphNode minting duplicate node ids (the
fixture never reserved the id it assigned) and promoted-widget labels
not inheriting the source slot label - both previously masked by the
mirror or the id collision.

Extension migration: read via node.isOutputConnected(slot) /
node.getOutputNodes(slot); mutate via node.connect() /
node.disconnectOutput(). Design record: docs/architecture/
output-slot-connectivity.md Decision 6.
2026-07-07 01:40:32 -07:00
DrJKL
b4fa1ed3f1 refactor: litegraph internals read output links from linkStore
All ~30 internal output.links read sites now go through the slotLinks
helpers or slot.isConnected; the mirror is write-only pending deletion.
Serialization derives outputs[].links from the store, sorted ascending
by id (equal to push order for organically built graphs) and null when
empty. configure() fires its output callbacks from the serialized
argument instead of the mirror copy.

Deliberate bugfix: disconnectInput passed the mirror-array index as the
OUTPUT slot number to onConnectionsChange; it now passes
link_info.origin_slot.
2026-07-07 01:12:55 -07:00
DrJKL
a4f38f71e2 refactor: output link queries exclude floating links; migrate last mirror readers
Link queries now return fully-assigned links only: buildOutputIndex skips
floating topologies, matching the output.links mirror they replace. The
domain rule lives in isFloatingTopology, which also replaces the
hand-rolled sentinel checks in rerouteStore, dynamicWidgets, and
widgetValuePropagation. The slot-drag disconnect check keeps its floating
awareness via slotFloatingLinks, its pre-store behavior.

New node/slotLinks helpers (outputHasLinks/outputLinkIds/outputLinks)
back the remaining app readers: rerouteNode, widgetInputs, groupNode,
proxyWidgetMigration, and useNodeReplacement now read the store instead
of output.links. Behavior change, deliberate: PrimitiveNode's
onLastDisconnect fires on disconnect-all, where the stale mirror
previously suppressed it.
2026-07-07 00:47:13 -07:00
DrJKL
603154e24f refactor: migrate output.links readers to linkStore topologies
getOutputSlotLinks now returns ReadonlySet<LinkTopology> instead of link
ids: floating links draw ids from a separate counter, so a bare id cannot
be resolved safely through graph.links, and the migrated readers need
endpoints anyway. Migrated: minimap link extraction, slot-drag disconnect
check (one store query replaces a mirror read plus a slotFloatingLinks
scan), widget value propagation, and matchType link revalidation.
rerouteNode/widgetInputs/groupNode stay on the mirror deliberately - they
read inside connection callbacks whose behavior depends on mid-mutation
mirror staleness - as do the serialized-workflow repair tools. Design
record updated.
2026-07-06 20:51:52 -07:00
DrJKL
5652a1c23f feat: answer output slot connectivity from linkStore and wire slot connected state
Adds a derived per-graph reverse index over link origins to linkStore,
exposing isOutputSlotConnected / getOutputSlotLinks as the output-side
mirror of the input queries. Target and origin slot keys are branded
separately so a key built for one index cannot be looked up in the other.
NodeSlots now passes connected to InputSlot and OutputSlot from the store;
the lg-slot--connected class reaches the dot via CSS, so SlotConnectionDot
needs no new prop. Design record in docs/architecture.
2026-07-06 20:22:45 -07:00
DrJKL
b71340970e fix: preserve null widget values across store, live widget, and callback
Default IBaseWidget TValue to the canonical WidgetValue so a widget value
may be null. The Vue update handler now assigns the same normalized value
to the store, live widget .value, and callback instead of coercing .value
to undefined. Coerce null/void to undefined only at the persisted
NodeProperty boundary.

Amp-Thread-ID: https://ampcode.com/threads/T-019f2a75-8d64-7605-980d-7aca51a5f19c
Co-authored-by: Amp <amp@ampcode.com>
2026-07-06 13:54:17 -07:00
DrJKL
32b3c28a1c That function was silly. 2026-07-06 13:54:17 -07:00
DrJKL
1b9906efbb refactor: pass single host instead of parents array to widget rows
The promoted-widget side panel only ever has one host SubgraphNode, so
collapse the parents: SubgraphNode[] prop to host?: SubgraphNode across
SectionWidgets/WidgetItem/WidgetActions and drop the [node] wrapper in
TabSubgraphInputs.

Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
2026-07-06 13:54:17 -07:00
DrJKL
9f3d11a0ba test: cover host-scoped widget actions and null value forwarding
- WidgetActions: show-input promotion, isLinked gating, favorite toggle
  by host node
- useProcessedWidgets: live update handler forwards null (not undefined)
  to the widget callback

Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
2026-07-06 13:54:17 -07:00
DrJKL
e6ab296c94 refactor: un-export isLinkedPromotion, assert demotion via link state
isLinkedPromotion has no production callers outside promotionUtils.
Drop its dedicated direct-unit describe block and rewrite the demoteWidget
tests that used it as an assertion oracle to check observable interior
link state instead.

Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
2026-07-06 13:54:17 -07:00
DrJKL
0679deb5f9 refactor: host-scope demoteRow and drop dead renameWidget parents
- demoteRow now removes the linked input via the host input slot we
  already have, instead of re-resolving the source and calling
  demotePromotedInput
- un-export demotePromotedInput (only internal callers remain)
- drop dead parents param from renameWidget/promptRenameWidget

Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
2026-07-06 13:54:17 -07:00
DrJKL
7400d4e6f2 refactor: remove inert isShownOnParents plumbing
isShownOnParents never changed observable behavior: for promoted rows
parents[0] === node (so favoriteNode was unchanged) and the toggle was
suppressed by isLinked; for interior rows it was always false. Its
Hide-toggle branch and parent-redirect were unreachable.

- drop isShownOnParents prop from WidgetItem and WidgetActions
- favorites key directly off node (was favoriteNode)
- WidgetActions toggle becomes a one-way Show Input action; remove the
  dead handleHideInput + demoteWidget usage and the hideInput locale key
- remove SectionWidgets.isWidgetShownOnParents

Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
2026-07-06 13:54:17 -07:00
DrJKL
1940be51d7 refactor: make promoted widget rows host-scoped, drop source traversal
SectionWidgets and WidgetActions identified promoted subgraph-input widgets
by walking live links back to the interior source. Per ADR0009 a promoted
widget is host-scoped (widget.widgetId = graphId:hostNodeId:inputName, also
carried on the host input slot), so reuse that instead:

- isLinked: inputForWidget(node, widget)?.widgetId != null
- isWidgetShownOnParents: match parent inputs by widget.widgetId
- handleHideInput: drop unreachable source-resolution branch, keep demoteWidget
- remove now-unused widgetPromotedSource export

Favorites keep their current (nodeLocatorId, widgetName) key; added a
TODO(ADR0009) to move to a pure WidgetId key (deferred: needs a persisted
format migration).

Amp-Thread-ID: https://ampcode.com/threads/T-019f2964-7a96-7579-8883-caeb3b2b07da
Co-authored-by: Amp <amp@ampcode.com>
2026-07-06 13:54:17 -07:00
DrJKL
a71df2f1b5 fix: track badge bucket membership so workflow loads wake the system
The system's watch tracked only the current graph id's bucket key; a
workflow load buckets nodes under a brand-new graph id the watcher had
never observed, so it never fired again and no badges were written
after any configure. registeredNodeIds now also reads the bucket map
size, so bucket creation and deletion wake readers regardless of which
id they last saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:54:09 -07:00
DrJKL
a310989209 refactor: trim badge comments to constraints the code cannot show
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:54:09 -07:00
DrJKL
84693005e4 fix: resolve badge graph id live and register nodes after onNodeAdded
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>
2026-07-06 13:54:09 -07:00
DrJKL
51ba284f7f fix: preserve legacy subgraph credits semantics and settle pricing cache
Wrapper aggregation counts inner api nodes (the old unconditional
per-api-node badge basis) rather than resolved credits rows, so a
pending async price still yields 'Partner Nodes x N'; the label is
localized via nodeBadge.partnerNodesCount. useNodePricing now caches
labels per signature: a leaf's own read and its wrapper's
promoted-override read previously shared one cache slot and evicted
each other, re-scheduling evaluations forever once both ran in eager
system effects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:54:09 -07:00
DrJKL
4c9a2b1312 docs: record slice B implementation and resolved badge decisions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:54:09 -07:00
DrJKL
7e08de9e3b feat: cut both renderers over to store badge rows
Legacy drawBadges renders the store rows through a per-node memoized
LGraphBadge cache (core parts joined id/lifecycle/source and truncated,
icons resolved via a badge icon registry) followed by the raw
node.badges extension surface, whose thunks still evaluate per frame.
The Vue partition reads the same rows — trimming lifecycle brackets and
swapping a built-in node's source row for the Comfy logo chip — plus the
raw extension surface.

useNodeBadge now bootstraps the badge system per root graph and forwards
the subgraph structure events; dynamic-pricing recalculation wiring is
kept. The usePriceBadge closures, the VueNodeData.badges mirror, its
property trigger, and node.badgePosition (no ecosystem uses; single
writer/reader) are deleted. node.badges itself stays: three custom-node
packs actively push to it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:54:09 -07:00
DrJKL
dce951b819 feat: aggregate subgraph credits in the badge system
SubgraphNode wrappers project their credits row from the inner nodes'
store rows: several priced inner nodes collapse to 'Partner Nodes x N',
a single one shows its price with the wrapper's promoted widget values
overriding the inner node's own. The aggregation tracks inner rows, the
registered-node set, and an exported structure revision for the events
stores cannot observe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:54:09 -07:00
DrJKL
0d0a3dbdec feat: tag core badge rows with projection part, keep raw text
Renderers own presentation: legacy joins id/lifecycle/source in its own
order with brackets intact, Vue trims and reorders. Rows carry the raw
projected text plus a part discriminator instead of pre-trimmed text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:54:09 -07:00
DrJKL
cb9ebecee8 fix: refuse badge row writes for unregistered nodes
A per-node effect flushing between unregisterNode and its scope being
stopped could re-create the bucket key and resurrect a removed node's
registration. Row writes (registerBadge, setBadgesOfKind) now require
an existing registration; registerBadge returns undefined when refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:53:59 -07:00
DrJKL
8c43b39e7b docs: record badge store slice A implementation state
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:53:59 -07:00
DrJKL
070ea46873 refactor: unexport module-internal NodeDefBadgeSources
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:53:59 -07:00
DrJKL
e09b47ffe5 feat: register node badge buckets at LGraph chokepoints
add/remove/clear (and subgraph-definition GC) now maintain each node's
nodeBadgeStore registration in the root bucket, following the
linkStore/rerouteStore chokepoint convention. Tests that construct
graphs without an active Pinia gain the standard testing-pinia setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:53:59 -07:00
DrJKL
c00b778059 feat: add reactive badge system writing store rows per node
startBadgeSystem watches the registered-node set of a root graph bucket
and runs one effect scope per node: a pure computeBadges projection of
nodeDef, badge-mode settings, palette, and pricing sources, written to
nodeBadgeStore as core/credits rows (node-badge-store.md decisions 3-4).
nodeBadgeStore gains the registerNode/unregisterNode/registeredNodeIds
registration surface the system consumes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:53:59 -07:00
DrJKL
64bce0e602 feat: add nodeBadgeStore with kind-partitioned badge rows
Root-graph-scoped buckets keyed by NodeId holding plain BadgeData rows,
per docs/architecture/node-badge-store.md decisions 1-2. Reads are
kind-ordered (core, credits, extension); the badge system rewrites its
kinds wholesale via setBadgesOfKind, extension rows use identity-checked
register/delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:53:59 -07:00
DrJKL
a6b9d4e254 docs: draft node badge store design record
Badges go straight to plain BadgeData rows in a dedicated store,
written by a reactive badge system; core badges materialize as rows
too, killing the dual derivation and positional slice(1). Adds badge
vocabulary to the domain glossary and repoints the node-data-store
badges row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:47:07 -07:00
DrJKL
dd3dd63b25 refactor: tighten graphId typing and badge test hygiene
Review follow-ups: drop comments that paraphrased the call, type the
graphId params as UUID, unify the undefined guard, hoist the mocked
graph id, and thread the shared pinia through renderUnified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:47:07 -07:00
DrJKL
c306c567f1 docs: draft node data store design record
Decisions 1 (single NodeState object, proxy-returning registration) is
agreed; field set, slot scope, and consumer contract are drafted pending
review. Records the shipped inputs[].link reader migration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:47:07 -07:00
DrJKL
2185f61b51 refactor: drop node-removal input reprojection loop
The refresh-all loop existed to poke inputs[].link readers after a
neighbor's links were torn down. Those readers now query linkStore,
which unregisters link state reactively during removal, so the loop and
refreshNodeInputs go. Verified against the vue-nodes legacy widget e2e
spec that motivated the loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:47:07 -07:00
DrJKL
29c7781d0a refactor: read widgeted-input connectivity from linkStore
NodeSlots.linkedWidgetedInputs, usePartitionedBadges, and trackNodePrice
queried the input.link slot mirror, which only stayed reactive through
the node:slot-links:changed -> refreshNodeInputs reprojection. All three
now query linkStore.isInputSlotConnected by root graph id and slot
index, so the slot-links handler and the emitter-less
node:slot-errors:changed handler are deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 13:47:07 -07:00
DrJKL
d07248890c docs: qualify layout-store cleanup and future SlotConnection writes
Link geometry is still removed from the layout store in the current
node-removal flow; the target-architecture matrix now marks the
SlotConnection write as future work; the proto-ECS inventory narrows
LayoutStore's link source to geometry.
2026-07-06 13:46:59 -07:00
DrJKL
f5c5d10897 fix: harden reroute chain, paste, and unpack edge cases
Review findings: validate the proposed parent chain in Reroute.parentId
(cycles were checked against the current chain, and a created cycle then
blocked repair); snapshot per-link reroute validity before connecting in
dropOnReroute; remove pruned reroutes from the paste result; truncate
cyclic or broken reroute chains during subgraph unpack instead of
throwing mid-mutation or stitching dangling ids; make RerouteChain.id
readonly; reuse the computed input index in SubgraphInputNode.
2026-07-06 13:46:59 -07:00
DrJKL
0b959753ee docs: align ECS architecture records with shipped link/reroute stores
Adds the missing link topology store design record, amends ADR 0008
(linkStore/rerouteStore amendment, Link and Slot component notes, new
supporting-document rows), and corrects the migration plan, target
architecture, proto-ECS store inventory, lifecycle scenarios, entity
interactions, and entity problems docs to match the shipped stores:
target-input-slot keying, root-graph-scoped buckets, derived reroute
membership, deleted layout-store connectivity mirror, and deleted
slot._floatingLinks sets.
2026-07-06 13:46:59 -07:00
DrJKL
2d51f2ca43 fix: address floating-link review findings
connectToSubgraphOutput wrote the floating link's origin instead of its
target — a subgraph output is the consuming end (target_id ===
SUBGRAPH_OUTPUT_ID everywhere else); the deleted slot mirror had masked
the bad endpoints from readers. removeInput/removeOutput now renumber
floating-link slots alongside real links, fixing stale attachment
indices that the mirror's slot-object identity used to paper over at
runtime (and that persisted stale into serialized floating links).
2026-07-06 13:46:59 -07:00
DrJKL
45d0566ff9 refactor: restructure subgraph-unpack reroute chain stitching
Replace the three interleaved walk-and-write loops (externalFirst
branches with per-hop error handling and outer-loop breaks) with
collect-then-stitch: walk the internal (migrated) and external chain
segments, order them by externalFirst, and write the chain pointers in
one generic pass. A truncated segment stops stitching at the break, as
before. Drops the order-dependent 'Missing Parent ID' false-positive
check from the migration loop.
2026-07-06 13:46:59 -07:00
DrJKL
62dedcc0dd fix: mirror reroute snapToGrid into the layout store
move() already forwards position changes to the layout store, but
snapToGrid mutated posInternal silently, leaving the Vue hit-testing
spatial index stale after a snapped drag.
2026-07-06 13:46:59 -07:00
DrJKL
01c0c8e86d refactor: derive slot floating links from link endpoints
A floating link has exactly one assigned endpoint, so its slot
attachment is fully encoded in its own origin/target fields. Replace
the hand-maintained slot._floatingLinks Sets (25+ write sites across
addFloatingLink/removeFloatingLink, setFloatingLinkOrigin, and every
FloatingRenderLink connect method) with the slotFloatingLinks(network,
side, nodeId, slot) derivation, and delete the property.

moveInputLink/moveOutputLink gain the node param their dragNewFrom*
siblings already take. FloatingRenderLink.connectToInput disconnects
the target input before re-targeting the floating link, since the
target slot's floating-link cleanup now sees the re-targeted link
immediately. The dead floating spread in hasRelevantOutputLinks
(LLink objects never passed its typeof-number filter) is removed.
2026-07-06 13:46:59 -07:00
DrJKL
fcba5b297c refactor: drop write-only reroute chain data from the layout store
The layout store wrote parentId and linkIds into its reroute Y.Maps but
only ever read position back — a third, dead copy of chain data now that
the reroute store owns the chain and membership is derived. Remove the
fields from CreateRerouteOperation, the mutation API, and the
useVueNodeLifecycle seeding. Also fold Reroute.removeFloatingLink into
removeAllFloatingLinks, its only caller.
2026-07-06 13:46:51 -07:00
DrJKL
bdf47aebca refactor: extract anchorRerouteChain and collapse chain-splice loops
Deduplicate the connect-path floating-chain cleanup (LGraphNode,
SubgraphInput, SubgraphOutput) into anchorRerouteChain, add
unregisterAllRerouteChains mirroring the link-side helper, splice
createReroute's live/floating loops into one pass over the actual link
objects (also removing the ambiguous cross-id-space lookup for a link
segment), inline the single-caller Reroute.update into setReroute, and
drop test pinia hooks shadowed by the file-level one.
2026-07-06 13:46:51 -07:00
DrJKL
43a4058be9 refactor: apply reroute-store review cleanups
Share EMPTY_MEMBERSHIP from the store, stop creating buckets inside the
membership computed, align registerRerouteChain's graph param with
registerLinkTopology, unnest the dedup id allocator, tighten store test
assertions, and clarify the design record (updateEndpoint guard,
load-time orphan drop).
2026-07-06 13:46:51 -07:00
DrJKL
18c9e769a4 refactor: derive reroute link membership from parentId chains
Reroute.linkIds/floatingLinkIds become derived accessors over the
reroute store's membership index; the ~10 manual mirror write sites
(connect paths, disconnect, floating add/remove, createReroute, subgraph
unpack, subgraph IO connect, LinkConnector) and validateLinks are
deleted. Configure and paste prune reroutes by derived totalLinks
instead of repairing stored sets, and serialization emits membership in
ascending link-id order.

LLink.disconnect unregisters the link before pruning so derived
counts exclude it. dropOnReroute resolves the drop target's source
output once before connecting moved links: re-anchoring the first
link's chain changes derived membership immediately, so a per-link
lookup could pick a not-yet-moved link's origin.
2026-07-06 13:46:51 -07:00
DrJKL
567643abe3 fix: deduplicate subgraph reroute ids per root graph on load
Reroute ids must be unique within a root graph now that the reroute
store keys every reroute in one root bucket, but subgraph definitions
from older frontends or external tools may number reroutes from
scratch. Add deduplicateSubgraphRerouteIds alongside the node-id dedup:
remaps colliding ids and patches reroute.parentId and link.parentId
references, advancing the shared state.lastRerouteId.
2026-07-06 13:46:51 -07:00
DrJKL
75feebdf5e feat: reroute chain store with derived link membership
New rerouteStore holds RerouteChain {id, parentId?, floating?} in
root-scoped buckets keyed by RerouteId, registered by reference; the
Reroute class adopts the store proxy as _chain so parentId/floating
writes are tracked. Link membership is derived from the links' parentId
chains via a per-graph computed reverse index (getMembership), split
into live and floating sets by unassigned endpoints.

LGraph._addReroute/_removeReroute are the map chokepoints (register,
unregister, layout cleanup); all reroute map mutation sites route
through them. delete reroute.floating sites become undefined
assignments since floating is now an accessor.
2026-07-06 13:46:51 -07:00
DrJKL
d14ed83e1e test: reroute serialization golden (key order + byte-identical round-trip) 2026-07-06 13:46:51 -07:00
DrJKL
cb3e757497 refactor: adopt the link store's reactive proxy as LLink._state
registerLink/updateEndpoint now return the store-held reactive state and
registerLinkTopology assigns it back to link._state (the BaseWidget
pattern), so field writes such as link.parentId are tracked by Vue
instead of silently mutating the raw object.
2026-07-06 13:46:51 -07:00
DrJKL
56f2abc519 docs: record reroute chain store design and start domain glossary 2026-07-06 13:46:51 -07:00
DrJKL
1fb64cec49 refactor: key the link store by target input slot
At most one live link can target a given input slot — litegraph
disconnects the previous link before connecting a new one — and every
store consumer queries by target, so make the target slot the primary
key instead of the link id:

- One target-keyed map per root bucket replaces the id-keyed map plus
  the separate target-slot index; connectedness queries are a single
  lookup and the index/unindex/reindex machinery disappears.
- Links without a unique target live in a per-graph side collection:
  floating links (either endpoint unassigned, and multiple can share an
  input slot) and links into SUBGRAPH_OUTPUT_ID (a constant shared by
  every subgraph in a bucket, which previously let sibling subgraphs
  clobber each other's index entries). Floating links no longer answer
  isInputSlotConnected, restoring parity with input.link semantics.
- updateEndpoint/deleteLink take the topology by reference and are
  identity-checked; updateEndpoint reports whether the link still holds
  a registration so LLink._graphId stays truthful.
- Delete deduplicateSubgraphLinkIds and its configure wiring: link-id
  collisions between subgraph definitions no longer touch the store, so
  workflows load without link-id rewrites again. Node-id dedup remains
  for widget-store keys.
2026-07-06 13:46:42 -07:00
DrJKL
1038264ae3 refactor: consolidate link-store unregistration and subgraph id dedup
Address code-quality review findings:

- removeFloatingLink now detaches via unregisterLinkTopology instead of
  deleting from the store directly, which had left a stale _graphId that
  silently dropped subsequent endpoint writes into the store's early
  return (covered by a new regression test).
- Extract unregisterAllLinkTopologies for the unregister-a-whole-graph
  loops duplicated between non-root clear() and subgraph-definition
  removal.
- Merge deduplicateSubgraphNodeIds/LinkIds behind a single
  deduplicateSubgraphIds entry point so the mutates-in-place link pass
  is an internal detail and configure makes one call; the module now
  exports one dedup function instead of two.
2026-07-06 13:46:42 -07:00
DrJKL
fc55165eae fix: unregister removed links from the link store at every delete site
Reinstate LGraph._removeLink as the delete-side counterpart to _addLink,
now safe because load-time dedup keeps link ids unique per root bucket:

- purgeOrphanedLinks and linkFixer route removals through _removeLink,
  which unregisters the link topology and layout entry; the survivor's
  registration is re-asserted since a purged duplicate may have owned its
  target-slot index entry (registerLink now re-indexes same-topology
  re-registration).
- LLink._graphId is only set when the store keeps the link's state, so a
  first-wins collision loser stays detached and its disconnect can no
  longer delete the winner's entry.
- Removing the last instance of a subgraph drops the definition's links
  (and floating links) from the store along with the definition.
- clear() on subgraphs and unconfigured (zero-uuid) graphs unregisters
  each link individually instead of leaking entries into the shared
  bucket.

Also cover floating-link id dedup with a fixture test and correct the
doc comment misplaced on numericSerializedNodeId.
2026-07-06 13:46:42 -07:00
DrJKL
da9280d13c fix: deduplicate subgraph link ids per root graph on load
Link ids, like node ids, must be unique within a root graph: the link
store keys every link in a root graph (its subgraphs' included) in one
bucket, but subgraph definitions each number their links from scratch, so
a subgraph link would collide with a root/sibling link and clobber its
target-slot index. That dropped externally linked subgraph inputs from the
store (promoted widgets rendered enabled instead of disabled; nested
prompts failed to resolve links).

Add deduplicateSubgraphLinkIds alongside the existing node-id dedup,
sharing its id allocator, and run it in root configure before subgraphs
are created. It remaps colliding link/floating-link ids and patches every
reference (node input.link / output.links, subgraph IO linkIds, reroute
linkIds), advancing state.lastLinkId. With ids unique, the store bucket is
collision-free and registration order no longer matters.

Drop the speculative LLink id-setter re-key and the removeFloatingLink
_graphId clear (no callers). YAGNI.
2026-07-06 13:46:42 -07:00
DrJKL
056531b59a fix: keep link-store registration first-wins to fix subgraph regression
The previous commit made linkStore.registerLink replace on id collision
and re-assert the survivor after duplicate purging, and routed dedup /
linkFixer removals through a store-deleting chokepoint. That assumed link
ids are unique within a graph bucket, which is false: sibling subgraphs
share one root-keyed bucket and reuse link ids, so a subgraph link would
clobber a root link's target-slot index. This dropped the externally
linked subgraph-node input from the store, so promoted widgets rendered
enabled instead of disabled and nested-subgraph prompts failed to resolve
links (e2e shards 6/7).

Revert to parent link-store semantics: registerLink is first-wins,
purgeOrphanedLinks removes from _links without touching the store (no
survivor re-assert), and linkFixer deletes from the map directly. Drop the
now-unused LGraph._removeLink. Verified the post-configure store state for
the failing fixtures is byte-identical to the pre-change baseline.

Cleanly separating the link store per owning graph (so ids don't collide)
is the real fix for the dedup/linkFixer store orphans; deferred.
2026-07-06 13:46:41 -07:00
DrJKL
b54a1a3471 fix: funnel link map mutations through LGraph._addLink/_removeLink
Route every graph link-map add/remove through single LGraph._addLink and
_removeLink chokepoints so the reactive linkStore (and layout geometry)
can no longer silently desync. Fixes three real desyncs where raw
_links.set/delete bypassed the store: subgraph reconnect never registered
the re-homed link, and purgeOrphanedLinks / linkFixer deleted links
without cleaning the store.

Also collapse the store's now-unused output-side surface: drop the
originSlotIndex, getOutputSlotLinks, and getNodeLinks (its sole consumer
was a no-op cleanup loop in layoutStore.handleDeleteNode, since node
removal disconnects links before onNodeRemoved fires). Reverting that loop
lets graphId come back off the persisted DeleteNodeOperation and removes
the layout->link cross-store call.

registerLink now always re-asserts its target-slot index and lets the
newest object win an id collision; the LLink id setter re-keys the store;
purgeOrphanedLinks re-asserts the survivor after removing duplicates that
shared its slot. AppModeWidgetList reads connectivity from linkStore for
consistency and reactivity.
2026-07-06 13:46:41 -07:00
DrJKL
31ef2d0dfe refactor: linkStore/test review cleanups
- name the nested link index map types and use them in the ref decls
- return the registered topology directly instead of re-reading with a cast
- replace the banned as-unknown-as Path2D stubs with fromPartial
- drop no-op UUID casts and a dead rootGraph optional chain
2026-07-06 13:46:41 -07:00
DrJKL
1d8e6e2b9b fix: address link store correctness findings
- clear LLink._graphId in disconnect() so post-disconnect endpoint writes
  fall through to the direct _state branch instead of no-opping
- dedupe the four endpoint setters through one applyEndpointPatch helper
- skip the target-slot index for floating links (UNASSIGNED_NODE_ID) in
  indexLink/unindexLink so a second floating link can't overwrite a real
  slot's entry
- drop the dead getNodeLayoutRef null-set branch and its zeroUuid sentinel;
  deletion always flows through layoutMutations.deleteNode with a graphId
2026-07-06 13:46:41 -07:00
DrJKL
327e3f84f0 fix: restore node:slot-links reprojection for un-migrated link consumers
NodeSlots.linkedWidgetedInputs and usePartitionedBadges still read
vueNodeData.inputs[].link directly; without refreshNodeInputs firing on
node:slot-links:changed those go stale on connect/disconnect.
2026-07-06 13:46:41 -07:00
DrJKL
cf8f87c37f test: link topology serialization goldens 2026-07-06 13:46:41 -07:00
DrJKL
af77a27567 refactor: drop node.inputs link mirror 2026-07-06 13:46:41 -07:00
DrJKL
8978502e8d refactor: drop node.inputs link fallback in buildSlotMetadata
buildSlotMetadata fell back to the node.inputs[].link mirror when
graphId was undefined, breaking single-source-of-truth from the
link-topology store and matching a fallback WidgetItem.vue already
lacks. Also collapse the redundant isInputSlotConnected +
getInputSlotLink pair into one getInputSlotLink call.
2026-07-06 13:46:41 -07:00
DrJKL
394ff10b23 refactor: read widget link state from linkStore 2026-07-06 13:46:41 -07:00
DrJKL
53dde242bd refactor: require graphId on DeleteNodeOperation 2026-07-06 13:46:41 -07:00
DrJKL
310de8ee08 refactor: remove layoutStore link connectivity mirror 2026-07-06 13:46:41 -07:00
DrJKL
d4ad799f0e refactor: register link topology into linkStore at canonical sites
Binds each LLink._state into useLinkStore by reference at every litegraph
create site (LGraphNode.connectSlots, LGraph.configure, addFloatingLink,
SubgraphInput/Output.connect), deletes on disconnect, and reindexes on
endpoint moves via a guarded setter delegation to updateEndpoint. Adds a
temporary DEV+pinia-gated subset invariant (removed in Task 6) asserting
graph._links is always a subset of the store.
2026-07-06 13:46:41 -07:00
DrJKL
2f1e14c53f refactor: back LLink topology with a single _state object 2026-07-06 13:46:41 -07:00
DrJKL
495ea990f3 feat: link topology store 2026-07-06 13:46:41 -07:00
github-actions
c8692de343 [automated] Update test expectations 2026-07-06 20:20:34 +00:00
DrJKL
1597d20bed fix: preserve null as distinct widget value in normalizeWidgetValue
Amp-Thread-ID: https://ampcode.com/threads/T-019f2953-7ed6-7088-826f-490ffa39ed0c
Co-authored-by: Amp <amp@ampcode.com>
2026-07-06 12:36:01 -07:00
DrJKL
e38dca7f5f Revert graph.spec.ts change 2026-07-06 12:36:01 -07:00
DrJKL
02790eaa28 fix: source WidgetItem link state from reactive vueNodeData
Link changes reach the panel through the node manager re-setting
vueNodeData (refreshNodeInputs), not through in-place mutation of
node.inputs, which fires no reactive signal. Read isLinked from the
manager's projection like the widget grid does, so a linked widget
disables without a bespoke event listener.
2026-07-06 12:36:01 -07:00
DrJKL
068eb13ee0 refactor: fold widget render state into a single store action
Combine widget value + render-state registration into one
registerWidget call so callers can't drift; register render state for
nested promoted inputs. Derive the live widget map once in
computeProcessedWidgets and drop the redundant slot-links listener in
WidgetItem.
2026-07-06 12:36:01 -07:00
GitHub Action
8d17098926 [automated] Apply ESLint and Oxfmt fixes 2026-07-06 12:36:01 -07:00
Alexander Brown
ec7dbde89c Apply suggestions from code review
Co-authored-by: Alexander Brown <DrJKL0424@gmail.com>
2026-07-06 12:36:01 -07:00
DrJKL
0175ab9aa2 refactor: share widget grid types and slim processed widgets
Extract WidgetGridItem and WidgetSlotMetadata into a shared types module and
make ProcessedWidget extend it, dropping the dead flat fields it duplicated.
Decompose computeProcessedWidgets into resolveLiveWidgetContext + processWidget,
dedupe ids via a Set, and stop exporting isWidgetVisible/hasWidgetError (tested
through the public compute path instead).

WidgetGrid drives widgets through :model-value so updateHandler is the sole
writer; preview items stay static. removeWidget now drops a widget from the
render order without purging its stored value, so remove-then-re-add restores
what the user set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:36:01 -07:00
DrJKL
0c073cac53 refactor: inline widget render key derivation
getWidgetIdentity took two params it never read and returned two
identical strings under different names. Inline it to a single renderKey
used for both dedupe and the render key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:36:01 -07:00
DrJKL
af8812e171 perf: resolve live widgets by id once per node
getLiveWidget rescanned every node.widgets entry for each widget id
(O(n^2) per node, per reactive recompute), and the same duplicate-index
scan was hand-copied into LGraphNode.vue. Extract mapLiveWidgetsById so
both build the WidgetId->widget map once. Also resolve the promoted
widget source a single time per widget instead of once for sourceWidget
and again for the error target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:36:01 -07:00
DrJKL
b8e8b5b53a refactor: derive widget render state through one helper
isDOMBackedWidget and the render-state object were copy-pasted into
BaseWidget, SubgraphNode, and AppModeWidgetList. Collapse both into
deriveWidgetRenderState in the canonical litegraph widget util so all
three register through a single derivation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:36:01 -07:00
DrJKL
003dcea90a refactor: drop dead widget spec store and replaceNodeWidgetOrder
The graphWidgetSpecs map, WidgetSpec type, registerWidgetSpec/getWidgetSpec,
and replaceNodeWidgetOrder had no production callers: specs always fell
through to nodeDefStore.getInputSpecForWidget, and no path pruned via
replaceNodeWidgetOrder. Also drop the unused getNodeWidgetIds litegraph
util and an inert File-array branch in normalizeWidgetValue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:36:01 -07:00
DrJKL
0fb8625abf docs: tighten WidgetGridItem comment 2026-07-06 12:36:01 -07:00
DrJKL
510dd01e8b refactor: drop verbose comment from getNodeWidgetIds 2026-07-06 12:36:01 -07:00
DrJKL
b8d319b187 refactor: drop getRegisteredNodeWidgetIds duplicate of getNodeWidgetIds
Once both just return `[...order]` they were identical; use
getNodeWidgetIds at the two internal call sites.
2026-07-06 12:36:01 -07:00
DrJKL
a505780953 perf: read widget order from the incremental index, not a full scan
`order` (graphNodeWidgetOrders) is already maintained per node on
register/delete, so the reconciliation-by-full-scan in the read helpers
was redundant. getRegisteredNodeWidgetIds and getNodeWidgetIds now return
`[...order]` directly, and replaceNodeWidgetOrder prunes by iterating the
node's registered ids instead of the whole graph.

This removes an O(graph-widgets) scan from hot paths (per-node on load,
per-rendered-node in computeProcessedWidgets) and stops getNodeWidgetIds
from splicing reactive state during a Vue computed read.
2026-07-06 12:36:01 -07:00
DrJKL
ebb0f25231 fix: lead combo preview options with an explicitly empty value
The preview's `value` kept an explicit empty-string widget value (nullish
coalescing) while `values` dropped it (truthy check), so a combo whose
provided value is "" no longer listed that value among its options. Match
the presence check so the selected value is always led in.
2026-07-06 12:36:01 -07:00
DrJKL
4fad4889fa refactor: drop duplicated name/type/value from WidgetGridItem
WidgetGrid now reads name/type/value from the widget's `simplified`
descriptor instead of duplicating them as top-level fields. A preview
item collapses to `{ simplified, vueComponent, visible, renderKey }`.
2026-07-06 12:36:01 -07:00
DrJKL
68255992ec refactor: drop usePreviewWidgets composable; simplify WidgetGrid
Build the preview widget list as a plain inline computed in
LGraphNodePreview (like the pre-refactor path) instead of a dedicated
composable. WidgetGrid now takes a narrow WidgetGridItem (a structural
subset of ProcessedWidget, so NodeWidgets is unchanged), defaults the
interactive-only fields, and derives gridTemplateRows itself — removing
that prop from both callers and the gridTemplateRows/visibleWidgets
computeds from useProcessedWidgets. A preview widget is now just its
name, type, value, simplified spec, component, and visibility.
2026-07-06 12:36:01 -07:00
DrJKL
b2c479479e fix: drop unused WidgetSlotMetadata export
Only ProcessedWidget needs to be public; it references WidgetSlotMetadata
internally, so exporting the latter tripped knip.
2026-07-06 12:36:01 -07:00
DrJKL
b49eac4753 refactor: render node preview from plain data, not the widget store
Extract the presentational grid from NodeWidgets into WidgetGrid, driven by
a plain ProcessedWidget[] prop. NodeWidgets keeps the store-backed
useProcessedWidgets path (and its interactivity, forwarded to the grid via
attribute fallthrough); LGraphNodePreview builds ProcessedWidget[] straight
from the node schema via a new pure usePreviewWidgets composable.

The preview no longer fabricates widgetValueStore entries: gone are the
synthetic graph-id counter, the register widget/render-state/spec writes,
the manual state patching, and the onUnmounted cleanup. A static preview
has no live graph, so it now touches no store-backed widget state at all.
2026-07-06 12:36:01 -07:00
DrJKL
4ddf08240d refactor: move preview widget store writes out of computed
LGraphNodePreview derived previewWidgetIds in a computed whose body wrote
to widgetValueStore (register widget/render-state/spec), so a "pure"
derivation was the write path. Split it into a pure previewWidgets
computed plus a watchEffect that performs the store registration, and drop
the redundant state.type reassignment. Behaviour is unchanged; the writes
now live where side effects belong.
2026-07-06 12:34:28 -07:00
DrJKL
d831b1de54 fix: use uuid subgraph ids in nested promoted model asset
The nested promoted-missing-model test asset used readable slug ids for
its subgraph definitions. createNodeLocatorId only accepts UUID subgraph
ids, so getLiveWidget could not resolve the interior nodes, and the stale
promoted combobox never rendered disabled. Real subgraphs always get uuid
ids; the asset now matches, which is the actual fix for the interior
disabled-state assertion.

Revert the useProcessedWidgets disabled-derivation change: with valid
asset ids the original logic is correct, so the refactor was unnecessary.
2026-07-06 12:34:28 -07:00
DrJKL
4fb92e0bf6 fix: derive vue widget disabled state from live input link
Query the live node's widget input slot (getSlotFromWidget) as the single
source of truth for whether a widget is linked/disabled, matching
LGraphNode.updateComputedDisabled. This replaces the buildSlotMetadata
snapshot derivation, which missed promoted interior subgraph widgets whose
input carries a link to the subgraph input node, leaving them enabled.

Also drop the runtime import('/src/...') setup from the legacy app-mode
spec (only resolvable against the dev server, not the CI dist build) in
favor of the native devtools widget plus enterAppModeWithInputs.
2026-07-06 12:34:28 -07:00
DrJKL
20fe604ff9 test: finish pinia setup for vue node unit tests 2026-07-06 12:34:28 -07:00
DrJKL
a99fe3a1df test: set up pinia for graph replacement tests 2026-07-06 12:34:28 -07:00
DrJKL
b6ebd544c7 fix: resolve vue widget rendering regressions 2026-07-06 12:34:28 -07:00
DrJKL
ee0f984c21 fix: separate widget spec state from rendering 2026-07-06 12:34:28 -07:00
DrJKL
43b4e50697 fix: preserve promoted widget render order 2026-07-06 12:34:28 -07:00
DrJKL
c6e67b38b7 fix: unify widget update handler paths 2026-07-06 12:34:28 -07:00
DrJKL
5b065a299e fix: sync widget order through store 2026-07-06 12:34:28 -07:00
GitHub Action
97298116ee [automated] Apply ESLint and Oxfmt fixes 2026-07-06 12:34:28 -07:00
DrJKL
a4eab62f35 refactor: render widgets from widget ids 2026-07-06 12:34:28 -07:00
DrJKL
4dac3ccb22 fix: restore Load3D widget rendering 2026-07-06 12:33:44 -07:00
DrJKL
66bad3b522 fix: stop renderer resolving promoted widgets 2026-07-06 12:33:43 -07:00
DrJKL
f1b98c63bd cleanup: minor cleanups 2026-07-06 12:33:43 -07:00
DrJKL
f717873025 knip unused exports 2026-07-06 12:33:43 -07:00
DrJKL
aa3d192636 refactor: store widget render state separately 2026-07-06 12:33:43 -07:00
DrJKL
5b98697bcf refactor: store widget render state separately 2026-07-06 12:33:16 -07:00
209 changed files with 10096 additions and 4934 deletions

View File

@@ -6,7 +6,7 @@
"nodes": [
{
"id": 3,
"type": "outer-subgraph-with-promoted-missing-model",
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
@@ -20,7 +20,7 @@
},
{
"id": 4,
"type": "outer-subgraph-with-promoted-missing-model",
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
@@ -38,7 +38,7 @@
"definitions": {
"subgraphs": [
{
"id": "outer-subgraph-with-promoted-missing-model",
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"version": 1,
"state": {
"lastGroupId": 0,
@@ -71,7 +71,7 @@
"nodes": [
{
"id": 2,
"type": "inner-subgraph-with-promoted-missing-model",
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
@@ -105,7 +105,7 @@
]
},
{
"id": "inner-subgraph-with-promoted-missing-model",
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"version": 1,
"state": {
"lastGroupId": 0,

View File

@@ -48,25 +48,6 @@ test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
await expect(comfyPage.canvas).toHaveScreenshot('node-badge-multiple.png')
})
test('Can add badge left-side', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const LGraphBadge = window.LGraphBadge!
const app = window.app as ComfyApp
const graph = app.graph
const nodes = graph.nodes
for (const node of nodes) {
node.badges = [new LGraphBadge({ text: 'Test Badge' })]
// @ts-expect-error - Enum value
node.badgePosition = 'top-left'
}
graph.setDirtyCanvas(true, true)
})
await expect(comfyPage.canvas).toHaveScreenshot('node-badge-left.png')
})
})
test.describe(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -8,25 +8,32 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
let legacyNodeId = toNodeId(10)
await test.step('setup', async () => {
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
const legacyNode = await comfyPage.nodeOps.addNode(
'DevToolsNodeWithLegacyWidget',
undefined,
{
x: 0,
y: 0
}
)
legacyNodeId = legacyNode.id
await comfyPage.appMode.enterAppModeWithInputs([
[String(legacyNodeId), 'legacy_widget']
])
})
const getWidth = () =>
comfyPage.page.evaluate(
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
toNodeId(10)
)
const getWidth = async () =>
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
?.width ?? 0
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const nodeRef = await comfyPage.nodeOps.getNodeRefById(legacyNodeId)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
@@ -36,8 +43,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
})
await test.step('Resize to update width', async () => {
await expect.poll(getWidth).toBeGreaterThan(0)
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')

View File

@@ -3,31 +3,43 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { TestGraphAccess } from '@e2e/types/globals'
import { toNodeId } from '@/types/nodeId'
test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
test('Should display added widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
const nodeId = toNodeId(
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => (node.widgets?.length ?? 0) === 1
)
if (!node) throw new Error('Node with one widget not found')
return String(node.id)
})
)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
const widgets = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-widget')
await expect(widgets).toHaveCount(1)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_a', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
}, nodeId)
await expect(widgets).toHaveCount(2)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_b', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
}, nodeId)
await expect(widgets).toHaveCount(3)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_c', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(4)
}, nodeId)
await expect(widgets).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {

View File

@@ -21,6 +21,22 @@ text below says "the World," read "the set of dedicated stores"; where it shows
`world.getComponent(id, Component)`, read the matching store getter (for
example `widgetValueStore.getWidget(widgetId)`).
### Amendment (2026-07-05, PRs 13436/13449)
Two stores joined the dedicated-store set: `linkStore` (link topology,
keyed by target input slot in root-graph-scoped buckets — see
[Link Topology Store](../architecture/link-topology-store.md)) and
`rerouteStore` (reroute chain state with link membership derived from
the links' `parentId` chains — see
[Reroute Chain Store](../architecture/reroute-chain-store.md)). Both
follow the proxy-returning registration pattern established by
`BaseWidget`/`widgetValueStore`: the store bucket is a `reactive(Map)`,
registration inserts the class's state object by reference and the class
adopts the reactive proxy read back from the bucket, so class field
writes are tracked without an action chokepoint. The `layoutStore` link
connectivity mirror and the `slot._floatingLinks` sets were deleted in
the same work; the layout store now holds geometry only.
## Context
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
@@ -115,6 +131,15 @@ Components are plain data objects — no methods, no back-references to parent e
| `LinkVisual` | `color`, `path`, `_pos` (center point) |
| `LinkState` | `_dragging`, `data` |
> **Amended (2026-07-05):** `LinkEndpoints` shipped as
> `LinkTopology { id, originNodeId, originSlot, targetNodeId,
targetSlot, type, parentId? }` in a dedicated `linkStore`, keyed by
> **target input slot** (not link id) in root-graph-scoped buckets, with
> floating and subgraph-output links in an unkeyed side set. `LLink`
> reads through the store's reactive proxy (`_state`). See
> [Link Topology Store](../architecture/link-topology-store.md).
> `LinkVisual` and `LinkState` remain unextracted.
#### Subgraph (Node Components)
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
@@ -140,6 +165,13 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator |
| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` |
> **Amended (2026-07-05):** the input side of `SlotConnection` is
> subsumed by the `linkStore` key — the input-slot→link mapping _is_ the
> store's primary index (`isInputSlotConnected` / `getInputSlotLink`).
> The `slot._floatingLinks` sets were deleted; floating-link attachment
> is derived from the links' own endpoints (`slotFloatingLinks`). The
> `input.link` / `output.links` class mirrors remain un-migrated.
#### Reroute
| Component | Data (from `Reroute`) |
@@ -148,6 +180,13 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| `RerouteLinks` | `parentId`, input/output link IDs |
| `RerouteVisual` | `color`, badge config |
> **Amended (2026-07-04):** `RerouteLinks` was superseded during design
> review. The stored component is chain state only —
> `RerouteChain { parentId, floating? }` — and link membership
> (`linkIds` / `floatingLinkIds`) is derived from the links' `parentId`
> chains rather than stored. See
> [Reroute Chain Store](../architecture/reroute-chain-store.md).
#### Group
| Component | Data (from `LGraphGroup`) |
@@ -271,6 +310,9 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Link Topology Store](../architecture/link-topology-store.md) | Design record for the `linkStore` — target-input-slot keying, root-scoped buckets, registration protocol |
| [Reroute Chain Store](../architecture/reroute-chain-store.md) | Design record for the `rerouteStore` — chain state, derived link membership, load-time id dedup |
| [Domain Glossary](../architecture/domain-glossary.md) | Canonical vocabulary for links, reroutes, chains, and membership |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, Thyseus, and Bevy — patterns adopted, departed, when to revisit |

View File

@@ -0,0 +1,50 @@
# Domain Glossary
Canonical vocabulary for the graph domain. Terms are added as they are
resolved during design work; keep entries implementation-free. Intended
to grow into a proper reference document.
Design records that rely on this vocabulary:
[Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md),
[Node Badge Store](node-badge-store.md),
[ADR 0008](../adr/0008-entity-component-system.md).
## Badges
- **Badge** — a small visual annotation rendered on a node: its numeric
id, lifecycle state, source pack, execution price, or an
extension-provided marker. Badges are presentation state; they never
affect execution and are never persisted with the workflow.
- **Badge kind** — the category a badge belongs to: **core** (identity /
lifecycle / source, projected from the node's definition and user
settings), **credits** (price of executing an API node, including
aggregated prices of nodes inside a subgraph), or **extension**
(provided by third-party code).
- **Badge source** — the domain state a badge's content is computed
from (settings, node definition, palette, pricing, widget values,
input connectivity). A badge is always a projection of its sources;
it is never authored directly by a user.
## Links & Reroutes
- **Link** — a directed data connection from one node's output slot to
another node's input slot. At most one live link targets a given input
slot.
- **Floating link** — a link with exactly one attached endpoint, kept
alive so a reroute chain survives disconnection. The unattached end is
unassigned.
- **Reroute** — a visual waypoint that a link's rendered path travels
through. Purely organisational; never affects data flow. A reroute's
identity is unique within a workflow, subgraphs included.
- **Reroute chain** — the ordered sequence of reroutes a link passes
through, from the node output toward the input. Each reroute names its
upstream neighbour via _parent_; the link names the chain's most
downstream reroute (the **terminal reroute**).
- **Link membership (of a reroute)** — the set of links whose chains pass
through that reroute. Membership is _defined by_ the chains: a link is a
member of exactly the reroutes on the chain walked from its terminal
reroute upstream. It is never authored independently of the chain.
- **Floating slot marker** — the annotation on the last reroute of a
floating chain recording which side (input or output) the chain still
faces.

View File

@@ -2,7 +2,7 @@
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md).
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Link topology and reroute chain state shipped later into `linkStore` (PR 13436, keyed by target input slot in root-graph-scoped buckets — see [link-topology-store.md](link-topology-store.md)) and `rerouteStore` (PR 13449, membership derived from the links' `parentId` chains — see [reroute-chain-store.md](reroute-chain-store.md)); `layoutStore` keeps link/reroute geometry only. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
@@ -66,6 +66,7 @@ sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant LM as useLayoutMutations()
participant LKS as linkStore
participant LS as layoutStore
participant WVS as widgetValueStore
participant NOS as nodeOutputStore
@@ -73,12 +74,14 @@ sequenceDiagram
Caller->>CS: removeNode(nodeId)
CS->>LS: read node links (incoming + outgoing)
LS-->>CS: linkIds
CS->>LKS: read node links (incoming + outgoing)
LKS-->>CS: link topologies
loop each linkId
CS->>LM: deleteLink(linkId)
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
loop each link
CS->>LKS: unregister link topology
Note over CS,LKS: via the LGraph._removeLink chokepoint —<br/>map delete + store unregistration
CS->>LS: drop link geometry
Note over LKS,LS: linkStore owns topology;<br/>layoutStore only drops geometry
end
loop each widget on node
@@ -93,15 +96,15 @@ sequenceDiagram
### Key Differences
| Aspect | Current | ECS |
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates layoutStore + widget/output stores |
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteLink()`/`deleteNode()` mutations per layout entry |
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | Coordinated: `deleteWidget`, `deleteLink`/`deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
| Aspect | Current | ECS |
| ------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates linkStore + layout/widget/output stores |
| Cleanup | Manual per-slot, per-link, per-reroute | linkStore unregistration per link (via `_removeLink`) + geometry drop per layout entry |
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
| Store cleanup | WidgetValueStore not cleaned up; link geometry still removed from LayoutStore | Coordinated: `deleteWidget`, linkStore unregister + `deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
## 2. Serialization
@@ -223,7 +226,7 @@ sequenceDiagram
end
opt has subgraph definitions
G->>G: deduplicateSubgraphNodeIds()
G->>G: deduplicateSubgraphNodeIds() + deduplicateSubgraphRerouteIds()
loop each subgraph (topological order)
G->>G: createSubgraph(data)
end
@@ -250,7 +253,7 @@ sequenceDiagram
end
G->>G: add floating links
G->>G: validate reroutes
G->>G: prune reroutes with derived totalLinks === 0
G->>G: _removeDuplicateLinks()
loop each serialized group
@@ -262,6 +265,8 @@ sequenceDiagram
Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside `node.configure()`. Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs.
Note on load-time id hygiene: root `configure()` deduplicates **node ids** and **reroute ids** across sibling subgraph definitions before configuring them (`deduplicateSubgraphNodeIds` / `deduplicateSubgraphRerouteIds`), because both share root-graph-scoped store buckets. Link-id dedup is deliberately absent — the linkStore key is the target input slot, not the link id, so duplicate link ids across definitions cannot collide. Orphaned reroutes are pruned by the derived `totalLinks === 0` check; the old `validateLinks` set-repair is gone (membership is derived, so there is no stored set to drift).
### ECS Flow
```mermaid
@@ -532,7 +537,7 @@ sequenceDiagram
participant G as LGraph
participant L as LLink
participant R as Reroute
participant LS as LayoutStore
participant LKS as linkStore
Caller->>N1: connectSlots(output, targetNode, input)
@@ -545,24 +550,24 @@ sequenceDiagram
end
N1->>L: new LLink(++lastLinkId, type, ...)
N1->>G: _links.set(link.id, link)
N1->>LS: layoutMutations.createLink()
N1->>G: graph._addLink(link)
G->>LKS: register topology (keyed by target input slot)
Note over G,LKS: chokepoint: _links.set + linkStore registration
N1->>N1: output.links.push(link.id)
N1->>N2: input.link = link.id
loop each reroute in path
N1->>R: reroute.linkIds.add(link.id)
end
N1->>R: anchorRerouteChain(graph, link)
Note over N1,R: clears floating markers on the chain —<br/>membership is derived from link.parentId,<br/>no per-reroute linkIds writes
N1->>G: _version++
N1->>G: incrementVersion()
N1->>N1: onConnectionsChange?(OUTPUT, ...)
N1->>N2: onConnectionsChange?(INPUT, ...)
N1->>G: setDirtyCanvas()
N1->>G: afterChange()
```
Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method.
Problems: the source node orchestrates everything — it reaches into the graph's link map (via the `_addLink` chokepoint), the target node's slot, and the version counter. Reroute membership no longer needs writes (derived via rerouteStore), but the slot mirrors (`output.links`, `input.link`) are still mutated by hand.
### ECS Flow
@@ -570,29 +575,28 @@ Problems: the source node orchestrates everything — it reaches into the graph'
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant LS as layoutStore
participant LM as useLayoutMutations()
participant LKS as linkStore
Caller->>CS: connect(outputSlot, inputSlot)
CS->>LS: read input slot link
CS->>LKS: getInputSlotLink(graphId, targetNodeId, targetSlot)
opt already connected
CS->>LM: deleteLink(existingLinkId)
CS->>LKS: unregister existing topology
end
CS->>LM: createLink(linkId, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
Note over LM,LS: createLink updates both slot endpoints<br/>and emits a command record
CS->>LKS: register LinkTopology {<br/> id, originNodeId, originSlot,<br/> targetNodeId, targetSlot, type<br/>}
Note over CS,LKS: the target-slot key IS the input-side<br/>slot connection — no separate endpoint update.<br/>Reroute membership derives from parentId;<br/>rerouteStore needs no write
```
### Key Differences
| Aspect | Current | ECS |
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (reads layoutStore) |
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `createLink()` command — endpoints + change tracking included |
| Reroute handling | Manual: iterate chain, add linkId to each | Reroute entries updated via layout mutations |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | `createLink(linkId, ...)` updates both endpoints |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
| Aspect | Current | ECS |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Orchestrator | Source node (reaches into graph, target slots) | ConnectivitySystem (reads linkStore) |
| Side effects | `incrementVersion()`, `setDirtyCanvas()`, `afterChange()`, callbacks | topology registration — endpoints + change tracking included |
| Reroute handling | None needed — membership derived from `parentId` chains (rerouteStore); `anchorRerouteChain` only clears floating markers | Same — derived membership is already the target shape |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Input side subsumed by the linkStore key; output side pending SlotConnection extraction |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
## 7. Copy / Paste

View File

@@ -12,7 +12,8 @@ For verified accuracy of these documents, see
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
> string IDs, one store per concern (widget values, DOM widgets, layout, node
> outputs, subgraph navigation, preview exposure). The earlier "single unified
> outputs, subgraph navigation, preview exposure, link topology, reroute
> chains). The earlier "single unified
> World with branded numeric entity IDs and `getComponent`/`setComponent`" model
> was rejected. PR 12617 shipped the first stores against composite
> `graphId:nodeId:name` string keys (`WidgetId`). Phases below are reframed
@@ -113,14 +114,25 @@ narrow accessor surface. There is no single container that fronts all entities.
Shipped stores:
| Store | File |
| ------------------------- | ----------------------------------------------- |
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
| Store | File |
| -------------------------- | ----------------------------------------------- |
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
| `linkStore` ✅ PR 13436 | `src/stores/linkStore.ts` |
| `rerouteStore` ✅ PR 13449 | `src/stores/rerouteStore.ts` |
`linkStore` holds `LinkTopology` records (`src/types/linkTopology.ts`) keyed by
target input slot (`` `${targetNodeId}:${targetSlot}` ``) in root-graph-scoped
buckets — subgraphs share their root's bucket; floating links and links
targeting subgraph outputs live in a per-graph unkeyed side set. `rerouteStore`
holds `RerouteChain` records keyed by `RerouteId` in root-graph-scoped buckets;
link membership is not stored but derived from the links' `parentId` chains.
Design records: [Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md).
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
@@ -260,6 +272,14 @@ link-endpoint records from the relevant stores:
Does not perform mutations yet — just queries. Validates that store connectivity
data is complete and consistent with the class-based graph.
> **Status (2026-07-05):** The reroute-membership query shipped as `linkStore` +
> `rerouteStore` (PRs 13436, 13449): "what links pass through this reroute" is
> derived per root graph by a cached reverse index over the links' `parentId`
> chains, and input-side connectivity is one lookup via
> `linkStore.isInputSlotConnected()` / `getInputSlotLink()`. Remaining:
> slot mirrors (`input.link` / `output.links`), output-side queries, and
> execution order.
**Risk:** Low. Read-only system with equivalence tests.
---
@@ -311,6 +331,12 @@ the system knowing about the callback API.
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
compatibility API during Phase 4.
> **Status (2026-07-05):** Link and reroute store registration now funnels
> through canonical `LGraph` mutation chokepoints: `_addLink`/`_removeLink` and
> `_addReroute`/`_removeReroute` pair every map mutation with store
> (un)registration, and `clear()` / subgraph-definition GC unregister whole
> graphs. The callback contract above and slot-mirror extraction remain.
**Risk:** High. Extensions depend on callback ordering and timing. Must be
validated against real-world extensions.
@@ -596,13 +622,15 @@ state between these calls.
The dedicated stores use per-concern keying strategies:
| Store | Key Format |
| ------------------------- | ---------------------------------- |
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
| `domWidgetStore` | Widget UUID |
| `layoutStore` | Raw nodeId/linkId/rerouteId |
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
| `subgraphNavigationStore` | subgraphId or `'root'` |
| Store | Key Format |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
| `domWidgetStore` | Widget UUID |
| `layoutStore` | Raw nodeId/linkId/rerouteId |
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
| `subgraphNavigationStore` | subgraphId or `'root'` |
| `linkStore` | `` `${targetNodeId}:${targetSlot}` `` (target input slot), root-graph-scoped buckets |
| `rerouteStore` | `RerouteId`, root-graph-scoped buckets |
ADR 0009 refines the promoted-widget target: promoted value widgets should use
host boundary identity (`host node locator + SubgraphInput.name`), not interior
@@ -629,7 +657,8 @@ Phase 0c (doc fixes) ─────────┤── no dependencies betwe
Phase 1a (branded WidgetId) ── ✅ shipped (PR 12617)
Phase 1b (store state shapes) ─┐── depends on 1a
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
Phase 1c (dedicated stores) ──┘── widgetValueStore + 7 others shipped
(PR 12617; linkStore PR 13436; rerouteStore PR 13449)
Phase 2a (Position via layoutStore) ─┐── depends on 1c
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c

View File

@@ -18,7 +18,12 @@ Map&lt;WidgetId, WidgetValue&gt;"]
DomWidgetStore["domWidgetStore
Map&lt;WidgetId, DomWidgetState&gt;"]
LayoutStore["layoutStore (Y.js CRDT)
nodeId / linkId / rerouteId → layout"]
nodeId / linkId / rerouteId → geometry"]
LinkStore["linkStore
rootGraphId → targetNodeId:targetSlot
→ LinkTopology"]
RerouteStore["rerouteStore
rootGraphId → RerouteId → RerouteChain"]
NodeOutputStore["nodeOutputStore
Map&lt;nodeLocatorId, outputs&gt;"]
SubgraphNavStore["subgraphNavigationStore
@@ -39,7 +44,8 @@ preview exposure state"]
RS -->|reads| Stores
SS -->|reads/writes| Stores
CS -->|reads/writes| LayoutStore
CS -->|reads/writes| LinkStore
CS -->|reads/writes| RerouteStore
LS -->|reads/writes| LayoutStore
ES -->|reads| NodeOutputStore
VS -->|reads/writes| LayoutStore
@@ -63,20 +69,31 @@ subgraphId:nodeId"]
NID["nodeId (raw)"]
LID["linkId (raw)"]
RID["rerouteId (raw)"]
TIS["targetNodeId:targetSlot
(root-graph-scoped bucket)"]
end
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
NLID -->|nodeOutputStore| W
NID -->|layoutStore| W
LID -->|layoutStore| W
RID -->|layoutStore| W
RID -->|layoutStore, rerouteStore| W
TIS -->|linkStore| W
```
`WidgetId = graphId:nodeId:name` is itself a branded string (see
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
outputs. `layoutStore` keys layout records by raw `nodeId` / `linkId` /
`rerouteId`. Each store enforces its own key shape; there is no single shared
entity-ID type across stores.
outputs. `layoutStore` keys geometry records by raw `nodeId` / `linkId` /
`rerouteId`. `linkStore` keys `LinkTopology` by **target input slot**
(`targetNodeId:targetSlot`) inside root-graph-scoped buckets — the link id is
NOT the key; at most one live link can target an input slot, so the target is
the natural primary key (see
[link-topology-store.md](link-topology-store.md)). Links without a unique
target (floating links, `SUBGRAPH_OUTPUT_ID` targets) live in a per-graph
unkeyed side set. `rerouteStore` keys `RerouteChain` by raw `rerouteId` in
root-graph-scoped buckets (see
[reroute-chain-store.md](reroute-chain-store.md)). Each store enforces its own
key shape; there is no single shared entity-ID type across stores.
Note: `graphId` is a scope identifier. It identifies which graph an entity
belongs to and forms the prefix of `WidgetId`. Subgraphs are nodes with a
@@ -177,14 +194,15 @@ target_id, target_slot, type"]
B5["resolve()"]
end
subgraph After["linkId-keyed components (layoutStore)"]
subgraph After["target-slot-keyed topology (linkStore) + unextracted state"]
direction TB
A1["LinkEndpoints
{ originId, originSlot,
targetId, targetSlot, type }"]
A2["LinkVisual
A1["LinkTopology — SHIPPED
{ id, originNodeId, originSlot,
targetNodeId, targetSlot, type, parentId? }
keyed by targetNodeId:targetSlot"]
A2["LinkVisual — not yet extracted
{ color, path, centerPos }"]
A3["LinkState
A3["LinkState — not yet extracted
{ dragging, data }"]
end
@@ -198,6 +216,26 @@ targetId, targetSlot, type }"]
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
```
`LinkTopology` has shipped in `src/stores/linkStore.ts`: `LLink._state` IS the
store entry — the class fields are accessors over the store's reactive proxy,
so the store and the instance cannot disagree. Registration is first-wins with
identity-checked delete/update. See
[link-topology-store.md](link-topology-store.md) for the full design record.
`LinkVisual` and `LinkState` remain on the `LLink` class.
### Reroute: RerouteChain (shipped)
Reroutes follow the same pattern. `RerouteChain { id, parentId?, floating? }`
lives in `src/stores/rerouteStore.ts`, keyed by `RerouteId` in
root-graph-scoped buckets; `Reroute._chain` is the store entry. Link
membership (`Reroute.linkIds` / `floatingLinkIds`) is **not stored** — it is
derived per root graph by a cached computed reverse index walking the links'
`parentId` chains, replacing ~10 hand-maintained write sites and the
`validateLinks` set-repair. See
[reroute-chain-store.md](reroute-chain-store.md). Reroute _position_ is not
yet migrated: `Reroute.posInternal` remains the source of truth, with the
layout store holding a partial `{ id, position }` mirror.
### Widget: Before vs After
```mermaid
@@ -248,8 +286,8 @@ graph TD
direction TB
CS["ConnectivitySystem
Manages link/slot mutations.
Writes: LinkEndpoints, SlotConnection,
Connectivity"]
Writes: LinkTopology (shipped),
SlotConnection (future), Connectivity"]
VS["VersionSystem
Centralizes change tracking.
Replaces 15+ scattered _version++.
@@ -312,9 +350,11 @@ graph LR
Exe["Execution"]
Props["Properties"]
WC["WidgetContainer"]
LE["LinkEndpoints"]
LE["LinkTopology
(linkStore — shipped)"]
LV["LinkVisual"]
SC["SlotConnection"]
SC["SlotConnection
(output side — future)"]
SV["SlotVisual"]
WVal["WidgetValue"]
WL["WidgetLayout"]
@@ -333,7 +373,7 @@ graph LR
LS -.->|read| WC
CS -->|write| LE
CS -->|write| SC
CS -.->|future write| SC
CS -->|write| Con
ES -.->|read| Con
@@ -349,6 +389,14 @@ graph LR
VS -.->|read| Con
```
ConnectivitySystem's `LinkEndpoints` write target is realized as
`LinkTopology` in `linkStore`. The input side of `SlotConnection`
(`input.link`) is subsumed by the linkStore key itself — "which link targets
this input slot" is the store's primary index
(`isInputSlotConnected` / `getInputSlotLink`) — though the `input.link` slot
mirror still exists on the class. The output side (`output.links[]`) remains
future extraction work.
## 4. Dependency Flow
### Before: Tangled References

View File

@@ -66,7 +66,7 @@ graph TD
Link -.->|"origin_id, target_id"| Node
Link -.->|"parentId"| Reroute
Slot -.->|"link / links[]"| Link
Reroute -.->|"linkIds"| Link
Reroute -.->|"linkIds (derived, rerouteStore)"| Link
Reroute -.->|"parentId"| Reroute
Group -.->|"_children Set"| Node
Group -.->|"_children Set"| Reroute
@@ -103,10 +103,14 @@ type: ISlotType"]
Link -.->|"parentId"| R1["Reroute A"]
R1 -.->|"parentId"| R2["Reroute B"]
R1 -.-|"linkIds Set"| Link
R2 -.-|"linkIds Set"| Link
R1 -.-|"linkIds (derived)"| Link
R2 -.-|"linkIds (derived)"| Link
```
`Reroute.linkIds` / `floatingLinkIds` are read-only accessors derived from the
links' own `parentId` chains by `rerouteStore` — membership is never stored
(see [reroute-chain-store.md](reroute-chain-store.md)).
### Subgraph Boundary Connections
```mermaid
@@ -142,14 +146,20 @@ graph TD
```mermaid
graph LR
Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink
origin_id=-1 or target_id=-1"]
origin or target = UNASSIGNED_NODE_ID"]
FL -->|"stored in"| FLMap["graph.floatingLinks Map"]
FL -->|"registered in"| SideSet["linkStore unkeyed side set
(no unique target slot)"]
FL -.->|"may pass through"| Reroute
Reroute -.-|"floatingLinkIds Set"| FL
Reroute -.-|"floatingLinkIds (derived)"| FL
FL -->|"on drop"| Permanent["Permanent LLink
(registered in graph._links)"]
(graph._links + linkStore target index)"]
```
A floating link's slot attachment is fully encoded in its own endpoints —
slots hold no floating-link sets (`slotFloatingLinks()` in `LLink.ts` derives
attachment by scanning `graph.floatingLinks`).
## 3. Rendering
How LGraphCanvas draws each entity type.
@@ -254,7 +264,7 @@ stateDiagram-v2
```mermaid
stateDiagram-v2
[*] --> Created: node.connect() or connectSlots()
Created --> Registered: graph._links.set(id, link)
Created --> Registered: graph._addLink(link)
state Registered {
[*] --> Active
@@ -268,12 +278,18 @@ stateDiagram-v2
note right of Created
new LLink(id, type, origin, slot, target, slot)
_addLink sets graph._links entry and
registers topology in linkStore
(keyed by target input slot).
Output slot.links[] updated.
Input slot.link set.
end note
note right of Removed
Removed from graph._links.
Removed from graph._links and
unregistered from linkStore
(via _removeLink or link.disconnect).
Link geometry dropped from layoutStore.
Orphaned reroutes cleaned up.
graph._version incremented.
end note
@@ -362,6 +378,10 @@ graph TD
WVS["WidgetValueStore
(Pinia)"]
PES["PreviewExposureStore
(Pinia)"]
LKS["LinkStore
(Pinia)"]
RRS["RerouteStore
(Pinia)"]
LM["LayoutMutations
(composable)"]
@@ -383,10 +403,20 @@ lastRerouteId, lastGroupId)"]
SGNode -->|"host-scoped preview exposures"| PES
PES -.->|"keyed by host node locator"| SGNode
%% LayoutMutations
%% LinkStore
Graph -->|"_addLink()/_removeLink() register/unregister"| LKS
Link <-->|"_state IS the store entry (endpoint accessors)"| LKS
LKS -.->|"keyed by rootGraphId + targetNodeId:targetSlot"| Link
%% RerouteStore
Graph -->|"_addReroute()/_removeReroute()"| RRS
Reroute <-->|"_chain (parentId, floating)"| RRS
RRS -.->|"derived linkIds membership"| Reroute
%% LayoutMutations (geometry only)
Node -->|"pos/size setter"| LM
Reroute -->|"move()"| LM
Link -->|"connectSlots()/disconnect()"| LM
Link -->|"disconnect(): drop link geometry"| LM
Graph -->|"add()/remove()"| LM
%% Graph state

View File

@@ -168,12 +168,24 @@ No central mechanism exists. It's easy to forget an increment (stale render) or
Domain objects call Pinia composables at the module level or in methods, creating implicit dependencies on the Vue runtime:
- `LLink.ts:24``const layoutMutations = useLayoutMutations()` (module scope)
- `Reroute.ts`same pattern at module scope
- `Reroute.ts:31``const layoutMutations = useLayoutMutations()` (module scope)
- `LLink.ts:7`imports the `layoutStore` singleton at module scope; `useLinkStore()` is called inside methods and helpers (needs an active Pinia, but not eagerly at import time)
- `Reroute.ts``useRerouteStore()` called inside methods for derived membership
- `BaseWidget.ts` — imports `useWidgetValueStore`
These make the domain objects untestable without a Vue app context.
### Reroute Membership Dual-Writes (solved)
`Reroute.linkIds` / `floatingLinkIds` were hand-maintained `Set`s written from
~10 scattered call sites (connect, disconnect, paste, configure, subgraph
pack/unpack, ...), with `Reroute.validateLinks()` repairing the inevitable
drift on load. Membership is now derived from the links' own `parentId`
chains via `rerouteStore` — the accessors are read-only, the write sites and
`validateLinks` are deleted, and orphaned reroutes are pruned by the derived
`totalLinks === 0` instead of set-repair. See
[reroute-chain-store.md](reroute-chain-store.md) (Decision 1).
### Change Notification Sprawl
`beforeChange()` and `afterChange()` (undo/redo checkpoints) are called from

View File

@@ -0,0 +1,119 @@
# Link Topology Store
Date: 2026-07-05 (retroactive design record; implemented in PR #13436)
Status: Accepted
Design record for extracting link topology into a dedicated store per
[ADR 0008](../adr/0008-entity-component-system.md). Amends the
`LinkEndpoints` component described there. The
[Reroute Chain Store](reroute-chain-store.md) builds directly on this
store; shared vocabulary lives in the
[Domain Glossary](domain-glossary.md).
## Decision 1: One state object, class reads through it
`LLink` no longer owns copies of its topology fields. A single plain
object,
```
LinkTopology { id, originNodeId, originSlot, targetNodeId, targetSlot,
type, parentId? }
```
backs the link: `LLink._state` holds it, and `id`, `type`, `origin_id`,
`origin_slot`, `target_id`, `target_slot`, and `parentId` are accessors
over it. Registration inserts that same object into the store by
reference and re-assigns `_state` to the reactive proxy read back from
the bucket, so subsequent class writes are Vue-tracked (the `BaseWidget`
pattern — see Decision 4 of the reroute chain store record). There is no
store-side copy to drift from the class: the store entry _is_ the
class's state.
The store is runtime state only; `LLink.asSerialisable` reads the same
fields it always did, and serialization goldens (key order plus
byte-identical round-trips) pin the wire format.
## Decision 2: Keyed by target input slot, not link id
The primary index is keyed by `` `${targetNodeId}:${targetSlot}` ``.
Two facts make this the right key:
- **The domain invariant**: at most one live link targets a given input
slot. The key is unique by construction for live links.
- **The dominant query**: consumers ask "is this input slot connected,
and by what?" (`isInputSlotConnected`, `getInputSlotLink`). The key
answers it in one lookup with no scan.
Link _ids_ are only unique per owning graph, not per root graph, so an
id-keyed root bucket needed a load-time link-id dedup pass and a
first-wins registration protocol to survive collisions across sibling
subgraph definitions. Re-keying by target slot deleted both: colliding
link ids never touch the index, so workflows load without link-id
rewrites.
Rejected: keeping the id key plus dedup/first-wins. That machinery
existed only to compensate for a key the queries never used.
## Decision 3: Root-graph-scoped buckets, unkeyed side set
Buckets are scoped by `rootGraph.id` — subgraphs share their root's
bucket — matching `widgetValueStore` and the later `rerouteStore`.
Re-keying entries to their owning graph was evaluated and rejected: it
reintroduces per-graph lifecycle bookkeeping the root scope avoids, and
no query wants owning-graph granularity that `graphTopologies` filtering
doesn't already provide.
Links without a unique live target go in a per-graph side `Set` instead
of the primary index:
- **Floating links** — exactly one assigned endpoint; the other is
`UNASSIGNED_NODE_ID`. A floating link attached to an input slot does
not answer `isInputSlotConnected`, preserving `input.link` semantics.
- **Links targeting `SUBGRAPH_OUTPUT_ID`** — the id is a shared
constant, so `targetNodeId:targetSlot` is not unique across the
subgraphs sharing a root bucket.
## Decision 4: Registration protocol
- `registerLink` is **first-wins**: if a different topology already
holds the target key, the call returns `undefined` and the loser
stays detached. `link._graphId` records a won registration; it is the
ownership marker that lets `unregisterLink` and re-registration no-op
safely for losers.
- `deleteLink` is **identity-checked** (`toRaw` comparison): only the
registered topology can vacate its slot.
- `updateEndpoint` re-keys atomically — identity-checked displace,
patch fields through a reactive wrapper, re-place under the new key —
and the move is **authoritative**: an incumbent under the new key is
evicted. First-wins only guards _registration_; a re-key is a move of
a link that already proved ownership by vacating its old key, so the
write wins. This is what keeps slot permutations safe when callers
re-key links one write at a time (input reorder, splice shifts): a
transiently evicted link re-places itself when its own endpoint write
arrives, and every lawful final state is collision-free. The
`reactive()` wrap stays even though registered links already hold the
proxy: the store is public API and may be handed a raw topology
object.
## Decision 5: Mutation chokepoints
All `graph._links` map mutation funnels through `LGraph._addLink` /
`_removeLink`, which pair the map write with store
registration/unregistration (and link-layout cleanup on removal).
`addFloatingLink` / `removeFloatingLink` do the same for the floating
map. `LLink.disconnect` performs the equivalent effects inline because
it only holds a `LinkNetwork`, and unregisters before reroute pruning so
derived reroute counts exclude the dying link. `clear()` and
subgraph-definition GC unregister whole graphs
(`unregisterAllLinkTopologies` / `clearGraph`).
## Scope
This design covers link topology (endpoints, type, chain terminus).
Link visual state (`color`, path caches) and the layout store's link
_geometry_ records are out of scope. The `output.links` and `input.link`
slot mirrors have since been deleted — the store is the single source
for slot connectivity in both directions (see
[output slot connectivity](output-slot-connectivity.md) Decision 6);
the remaining fields are deprecated warning getters kept as extension
migration telemetry.

View File

@@ -0,0 +1,188 @@
# 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](link-topology-store.md),
[reroute chain store](reroute-chain-store.md), and the
[node data store draft](node-data-store.md)
Design record for extracting node badges off `LGraphNode` instances into
a dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
going straight to plain-data components — no interim closure storage.
Vocabulary: [domain glossary § Badges](domain-glossary.md#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`/`clear`
chokepoints call the import-light store trio
(`registerNode`/`unregisterNode`/`clearGraph`), and the system watches
`registeredNodeIds` to 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
takes `resolveGraphId`/`resolveNode` seams and is bootstrapped once at
the app layer by the `Comfy.NodeBadge` extension (`useNodeBadge`).
The graph id is resolved live on every recompute because
`LGraph.clear()` and `configure()` 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.add`
registers the node only after `onNodeAdded`: the store write wakes
the system's watcher and queues Vue's flush microtask, which must not
overtake the paste-scan microtask `useErrorClearingHooks` enqueues
from `onNodeAdded`.
- The shell still reads `node.constructor.nodeData` and `node.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: `HideBuiltIn` respected 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 gains
`HideBuiltIn` handling 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 `SubgraphNode` credits 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 to `Partner 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.
`useNodePricing` caches 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 `drawBadges` renders the store rows through a per-node
memoized `LGraphBadge` cache (decision 5); `iconKey` resolves through
a generic litegraph `badgeIconRegistry`, with the `credits` icon
registered by the system module.
## Resolved decisions (2026-07-06 interview)
1. **Legacy `node.badges` surface — 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 splices `LGraphBadge`
instances; 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.
2. **`node.badgePosition` — deleted**, along with the `BadgePosition`
enum. Zero uses outside this repo; the legacy canvas now always
renders badges top-right (the only surviving behaviour).
3. **Subgraph credits aggregation triggers — event-bumped revision.**
`litegraph:set-graph`, `subgraph-converted`, and
`afterConfigureGraph` bump `bumpSubgraphCreditsRevision()`; swap to
reactive `SubgraphStructure` dependencies 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.

View File

@@ -0,0 +1,139 @@
# Node Data Store
Date: 2026-07-05
Status: Draft (design interview in progress; follow-up to the
[link topology store](link-topology-store.md) and
[reroute chain store](reroute-chain-store.md))
Design record for extracting the remaining Node-owned components into a
dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
eliminating the `VueNodeData` mirror and most of
`src/composables/graph/useGraphNodeManager.ts`.
## Decision 1: One store, one plain state object per node
`nodeDataStore` holds a single plain `NodeState` object per node,
registered by reference with proxy-returning registration (the
`BaseWidget` pattern, [reroute store Decision 4](reroute-chain-store.md)).
ADR 0008's Node component rows (`NodeVisual`, `Execution`, ...) become
field groupings inside `NodeState`, not separate records or stores.
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `NodeId`.
Node-id uniqueness across sibling subgraph definitions is already
guaranteed by the load-time dedup pass
(`src/lib/litegraph/src/subgraph/subgraphDeduplication.ts`).
## Decision 2: Field set — what is NodeState, what is elsewhere
```
NodeState {
id: NodeId
graphId: UUID // owning (sub)graph — partitioning + locator ids
type: string // identity, with apiNode?: boolean
title: string
titleMode?: TitleMode
mode: number
flags: { collapsed?, pinned?, ghost? }
color?: string
bgcolor?: string
shape?: number
resizable?: boolean
showAdvanced?: boolean
}
```
Excluded — owned or derived elsewhere; referencing them here would be a
mirror (the hard constraint of this phase):
| Field | Owner |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `selected` | `canvasStore.selectedNodeIds` (already what `LGraphNode.vue` reads) |
| `executing` | `executionStore` via `useNodeExecutionState` (already what Vue reads) |
| `hasErrors` | derived from `executionErrorStore` / missing-model/media stores; `node.has_errors` stays a legacy-canvas class field written by `useNodeErrorFlagSync` |
| position / size / z | `layoutStore` |
| widget values / order | `widgetValueStore` |
| input link connectivity | `linkStore` (`getInputSlotLink` / `isInputSlotConnected`) |
| `badges` | `nodeBadgeStore` — plain `BadgeData` rows written by a badge system; see [Node Badge Store](node-badge-store.md) |
| `inputs` / `outputs` | deferred — see Decision 3 |
`VueNodeData.selected` and `.executing` are dead fields today (no
production consumer reads them); they are deleted, not migrated.
## Decision 3: Slot arrays deferred; `inputs[].link` readers migrate now
`NodeInputSlot` / `NodeOutputSlot` are class instances with methods —
Slot entity extraction (ADR 0008 `SlotIdentity` etc.) is its own future
phase. The slot arrays stay class-side, keeping the `shallowReactive`
graft for renderer reactivity.
What this phase does remove is the last `inputs[].link` dependency: the
three remaining readers (`nodeDataUtils.linkedWidgetedInputs` used by
`NodeSlots`, and `usePartitionedBadges`' badge computed plus its
exported `trackNodePrice`) move to `linkStore.isInputSlotConnected`
queries — presence is all any of them needed — which deletes the
`node:slot-links:changed``refreshNodeInputs` reprojection in
`useGraphNodeManager`, the dead `node:slot-errors:changed` handler
(zero emitters repo-wide), and the node-removal refresh-all loop.
Shipped ahead of the store itself (2026-07-05).
## Decision 4: Renderer consumes the NodeState proxy, `VueNodeData` dies
`GraphCanvas` iterates the store's bucket for the active graph (filtered
by `NodeState.graphId`) and passes the reactive `NodeState` proxy down
the existing prop-drilling path (`LGraphNode``NodeHeader` /
`NodeSlots` / `NodeContent` / `NodeWidgets`). Children read proxy fields
directly; Vue tracks the store state, so the per-property
`node:property:changed` → snapshot-rewrite handlers in
`useGraphNodeManager` are deleted wholesale.
Slot arrays reach `NodeSlots` via the existing live-node access
(`getNodeByLocatorId`), not through `NodeState`.
`LGraphNodePreview` constructs a synthetic `NodeState` (as it does a
synthetic `VueNodeData` today). `AppModeWidgetList` stops calling
`extractVueNodeData` and reads the registered `NodeState` + live node.
## Decision 5: Registration lifecycle and class adoption
Follows the shipped trio convention (`LLink` / `Reroute`):
- `LGraphNode` constructs its `_state: NodeState` at instantiation;
`registerNodeState(graph, node)` inserts it by reference and the class
adopts the returned reactive proxy; `node._graphId` (root id) is the
registration-ownership marker.
- Chokepoints: `LGraph.add` / `LGraph.remove` (the canonical sites),
`unregisterAllNodeStates(graph)` on graph `clear()`, identity-checked
delete (`toRaw` compare) so only the registered state vacates its key.
- Class fields become accessors reading through `_state`.
`LGraphNodeProperties`' instrumented descriptors keep their
get/set + `node:property:changed` emission but store the value in
`_state` instead of a closure — trigger consumers (minimap,
`useErrorClearingHooks`) keep working unchanged.
- Serialization is unaffected: `serialize()` reads the same properties
through the accessors.
## Decision 6: What remains of useGraphNodeManager
Deleted: `extractVueNodeData`, the `vueNodeData` map, all
`node:property:changed` handlers, `syncWithGraph`, `getNode()`
(consumers use `graph.getNodeById`), the `node:slot-links:changed`
handler (Decision 3).
Remaining renderer-side lifecycle, slimmed into `useVueNodeLifecycle`
(or a small successor):
- layoutStore seeding on node add/remove (`createNode`/`deleteNode`
layout mutations, including the `onAfterGraphConfigured` deferral) —
layout is renderer policy, not entity data.
- `node:slot-label:changed` slot-array reprojection — dies with the
Slot extraction phase.
## Scope
Covers node shell state, the `VueNodeData` deletion, and the
`inputs[].link` reader migration. Out of scope: Slot entity extraction,
`Properties` (`properties` / `properties_info`) and `NodeType` metadata
beyond `type`/`apiNode` (`category`, `nodeData`, `description` remain on
the class/constructor), badges, `WidgetContainer` (already owned by
`widgetValueStore`), and command-pattern mutators (future work per
ADR 0003/0008).

View File

@@ -0,0 +1,250 @@
# Output Slot Connectivity
Date: 2026-07-06
Status: Accepted (follow-up to the
[link topology store](link-topology-store.md); the minimal, non-breaking
slice of the deferred `SlotConnection` component work in the
[ECS migration plan](ecs-migration-plan.md))
Design record for answering "is this output slot connected, and by what
links?" from `linkStore` instead of the `output.links[]` mirror. It is
the output-side counterpart to the input-side migration shipped in
[node-data-store Decision 3](node-data-store.md) (#13455). Wiring it lets
`SlotConnectionDot` show connected state and removes the last renderer
dependency on `output.links[]`.
This phase does not extract a Slot entity, add a store, or delete any
mirror field. Those stay in the deferred `SlotConnection` phase. What it
does is the smallest change that clears the reader debt and lets the
output dot show connection state.
## Motivation
`SlotConnectionDot.vue` colors slots by type only. It carries a
`//TODO Support connected/disconnected colors?`. `InputSlot` and
`OutputSlot` already declare `connected` / `compatible` props and apply
the `lg-slot--connected` / `lg-slot--compatible` classes, but
`NodeSlots.vue` passes neither, so the styling path is built but unwired.
Input connectivity is already answerable through
`linkStore.isInputSlotConnected` (shipped in #13455). The matching output
query does not exist yet, so the output dot cannot be wired and
`output.links[]` stays the only source. This phase adds that query and
consumes it.
## Decision 1: Extend `linkStore`, do not add a store or a Slot entity
Output connectivity is link topology: the mirror image of the input side
the store already owns. It belongs in `linkStore`, not a new `slotStore`.
A dedicated Slot store (plain `SlotIdentity` / `SlotVisual` rows, retiring
the slot class instances) is the full `SlotConnection` phase and is out
of scope here. Introducing it now would be premature per the
node-data-store record, which keeps the slot arrays class-side.
This phase adds read-only accessors over state the store already holds.
It needs no new plain-data component, registration trio, chokepoint, or
class adoption, though each sibling store required all four.
## Decision 2: Output connectivity is a derived reverse index
The store already exposes `graphTopologies(graphId)` over every
registered `LinkTopology`. "Which links leave output slot _(node, slot)_"
is a reverse index over origin endpoints. This is the same pattern
`rerouteStore` uses for link membership
([reroute store](reroute-chain-store.md)): a per-graph cached `computed`
that Vue invalidates when link state changes. Nothing is stored, so the
membership is always derived and cannot drift from the topology.
```ts
type OriginIndex = Map<
OriginSlotKey /* `${originNodeId}:${originSlot}` */,
Set<LinkTopology>
>
const outputIndexes = new Map<UUID, ComputedRef<OriginIndex>>()
function outputIndex(graphId: UUID): ComputedRef<OriginIndex> {
const existing = outputIndexes.get(graphId)
if (existing) return existing
const next = computed(() => {
const index: OriginIndex = new Map()
for (const t of graphTopologies(graphId)) {
if (t.originNodeId === UNASSIGNED_NODE_ID) continue
const key = originKey(t.originNodeId, t.originSlot)
const links = index.get(key) ?? new Set<LinkTopology>()
links.add(t)
index.set(key, links)
}
return index
})
outputIndexes.set(graphId, next)
return next
}
```
The index spans both collections that `graphTopologies` yields, the keyed
targets and the unkeyed side set, so a floating link with an assigned
origin (one endpoint only) still reports its output as connected. A
`SUBGRAPH_INPUT_ID` origin indexes like any other id.
## Decision 3: Two public queries, mirroring the input pair
```ts
isOutputSlotConnected(graphId, nodeId, slot): boolean
getOutputSlotLinks(graphId, nodeId, slot): ReadonlySet<LinkTopology>
```
These match `isInputSlotConnected` / `getInputSlotLink` in name and shape.
The input side returns a single `LinkTopology`, since at most one link
targets an input; the output side returns a set, since an output fans out
to many. The set holds topologies rather than bare `LinkId`s: floating
links draw their ids from a separate counter (`_lastFloatingLinkId`), so
an id alone cannot be resolved safely through `graph.links` — a floating
id can collide with an unrelated regular link. Topologies carry the
endpoints most readers want and make floating links identifiable
(`targetNodeId === UNASSIGNED_NODE_ID`) without resolution.
## Decision 4: Migrate readers incrementally, keep the mirror written by hand
This follows the #13455 discipline for `input.link`: move the readers to
the store, leave the field in place, and keep writing it at the
chokepoints.
`output.links[]` has roughly 200 read sites but only about 12 write
sites, all in `LGraphNode`, `LGraph`, and the subgraph paths. Most of the
reads are litegraph-internal graph algorithms such as traversal, dedup,
and serialization. Those can keep reading the mirror indefinitely; they
are not renderer policy and nothing blocks on them.
Migrated readers: the output dot's connected state (`NodeSlots`), the
minimap link extraction (`AbstractMinimapDataSource`), the drag-start
disconnect check (`useSlotLinkInteraction`, where one store query replaces
a mirror read plus a `slotFloatingLinks` scan), widget value propagation
(`widgetValuePropagation`), and matchType link revalidation
(`dynamicWidgets.changeOutputType`).
## Decision 6: Delete the mirrors (implemented; extended to `input.link`)
The runtime `output.links[]` field and all nine of its write sites are
deleted. The same recipe has since been applied to `input.link`: the
field is a deprecated warning getter, litegraph and app code read through
the slotLinks input helpers (`inputHasLink`, `inputLinkId`, `inputLink`)
or `node.isInputConnected` / `node.getInputLink`, serialization derives
`inputs[].link` from the store, and the mirror-carried association
shuffles were reworked — `fixLinkInputSlots` consumes the serialized
graph data, dynamicWidgets' group rebuilds carry slot→link association
in a module-scoped WeakMap refreshed from the store, and link
deduplication selects survivors from the store registration (the
`repairInputLinks` mirror repair is gone; the derived view cannot be
wrong). The store is the single source; litegraph internals read through
the pure helpers in `node/slotLinks.ts` (`outputHasLinks`,
`outputLinkIds`, `outputLinks`), and `NodeOutputSlot.isConnected`,
`serialize`, and `configure` derive from the store. Details:
- **Wire format unchanged.** `outputAsSerialisable` / `toJSON` emit the
serialized `outputs[].links` array from the store, sorted ascending by
id (a determinism choice — equal to push order for organically built
graphs) and `null` when empty. `configure` fires its output
`onConnectionsChange` callbacks from the serialized argument.
- **Floating links are never returned by link queries.** They are
reroute-chain scaffolding, named by `isFloatingTopology`
(`src/types/linkTopology.ts`). `isOutputSlotConnected` /
`getOutputSlotLinks` see fully-assigned links only, matching the
mirror they replace; the one consumer with legacy floating-aware
behavior (the slot-drag disconnect check) keeps its own
`slotFloatingLinks` scan.
- **Extension compat = deprecation telemetry, not compatibility.** A
read-only prototype getter on `NodeOutputSlot` returns a fresh
store-derived `LinkId[] | null` and fires `warnDeprecated`. Writes fire
their own `warnDeprecated` naming the replacement APIs
(`node.connect()` / `node.disconnectOutput()`) and are otherwise
ignored — the store stays authoritative and legacy writers degrade
gracefully instead of crashing. A bare accessor-only property would
instead throw an unactionable `TypeError` in strict mode.
`INodeOutputSlot` keeps `links` as `@deprecated readonly` so
`'links' in slot` discriminants still compile and hold at runtime via
the prototype. The mirror keys are non-enumerable: `{ ...slot }`,
`Object.assign({}, slot)`, and `Object.keys(slot)` do not carry
`link` / `links` (use the store queries to snapshot connectivity);
`JSON.stringify` still emits them via `toJSON`. `addInput` / `addOutput`
drop a stale `link` / `links` key from `extra_info` instead of letting
`Object.assign` collide with the accessor — the store is not consulted
or mutated by such values.
- **Duck-typed slots are upgraded in place.** `_setConcreteSlots` writes
the concrete `NodeInputSlot` / `NodeOutputSlot` wrappers back into
`node.inputs` / `node.outputs`. The concrete classes resolve their slot
index by identity (`inputs.indexOf(this)`), so a wrapper that is not
the array entry would permanently read as disconnected. Plain-object
slots pushed directly into the arrays are therefore upgraded at the
next concretisation (`configure`, paste/convert paths, every canvas
draw); until then their stale `link` value is dead data. Extensions
should use `node.addInput()` / `node.addOutput()` instead of pushing
literals.
- **Serialized-data operators are untouched.** `linkFixer` (serialized
branch), `migrateReroute`, and `unpackSubgraph`'s pre-configure strip
operate on the wire format, which still carries `links`.
- **Behavior changes, deliberate:** `PrimitiveNode.onLastDisconnect`
fires on disconnect-all (the stale mirror previously suppressed it);
`disconnectInput` passes `link_info.origin_slot` — not the old
mirror-array index — as the OUTPUT slot in `onConnectionsChange`;
serialization emits `null` where a lingering `[]` used to persist.
Extension migration map: presence → `node.isOutputConnected(slot)` /
`slot.isConnected`; enumerate targets → `node.getOutputNodes(slot)`;
mutate → `node.connect(...)` / `node.disconnectOutput(slot, target?)`.
App/Vue code uses `useLinkStore().getOutputSlotLinks(...)` (reactive).
## Decision 5: Wire connected state into the dots (the payoff)
`NodeSlots.vue` passes `connected` to each slot:
- input: `linkStore.isInputSlotConnected(rootGraphId, nodeId, index)`
(already available)
- output: `linkStore.isOutputSlotConnected(rootGraphId, nodeId, index)`
(Decision 3)
`InputSlot` and `OutputSlot` already forward `connected` to the
`lg-slot--connected` class. `SlotConnectionDot` needs no prop of its own:
the wrapper class is an ancestor styling hook
(`.lg-slot--connected .slot-dot`), so the visual is one CSS rule away once
the design-standards check (open question 3) picks it. Threading a
`connected` prop into the dot before that would be plumbing with no
consumer. `compatible` stays driven by the existing drag state
(`useSlotLinkDragUIState`), which this phase leaves alone.
## Scope
In scope: the derived output-side index, the two queries, the output-dot
reader migration, and wiring `connected` from `NodeSlots` into
`InputSlot` / `OutputSlot` (whose `lg-slot--connected` class reaches the
dot via CSS).
Since implemented by Decision 6: `output.links` reader migration, write
sites, and field deletion.
Out of scope, each a piece of the deferred `SlotConnection` phase:
- Slot entity extraction: `SlotIdentity`, `SlotVisual`, and retiring the
`NodeInputSlot` / `NodeOutputSlot` class instances and their
`shallowReactive` graft.
- A `compatible`-state source beyond the current drag UI state.
- Floating/regular link-id counter unification (mint floating ids from
the shared `state.lastLinkId`) — only if the internal wart ever bites;
nothing resolves store-returned ids against `graph.links` across the
id spaces anymore.
## Open questions
1. **Index granularity.** One `computed` per graph rebuilds the whole
origin index on any link change in that graph, the same cost model as
`rerouteStore`. If profiling on large graphs shows this is hot, the
fallback is an incrementally maintained `Map` updated in `place` and
`displace`, which is more code and carries drift risk. Start with the
derived version and measure before optimizing.
2. **Return type of `getOutputSlotLinks`.** Resolved: a set of
`LinkTopology`, per Decision 3. Bare ids looked sufficient until the
reader migration surfaced both the endpoint needs (minimap, widget
value propagation) and the floating-id collision hazard.
3. **Dot visual.** What "connected" looks like on the dot (fill, ring, or
opacity) is a design-standards question rather than an architecture
one. Check the Comfy Design Standards before implementing Decision 5.

View File

@@ -6,20 +6,30 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Six dedicated stores extract entity state out of class instances into focused,
Eight dedicated stores extract entity state out of class instances into focused,
queryable registries, each owning one concern. Promoted value-widget topology is
no longer a store; ADR 0009 represents it as ordinary linked `SubgraphInput`
state, and promoted value data lives in `WidgetValueStore` keyed by the input's
`WidgetId`.
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------- | ---------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ---------------------------- | ----------------- | --------------------------------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link geometry, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
| LinkStore | `LLink` | Root graph | `` `${targetNodeId}:${targetSlot}` `` (target input slot) | Plain `LinkTopology` object |
| RerouteStore | `Reroute` | Root graph | `RerouteId` | Plain `RerouteChain` object |
**Update (2026-07-05):** `LinkStore` (`src/stores/linkStore.ts`, PR #13436) and
`RerouteStore` (`src/stores/rerouteStore.ts`, PR #13449) hold plain-data records
in reactive `Map` buckets — not Y.js — scoped by root graph (subgraphs share
their root's bucket). Floating links and links targeting subgraph outputs live
in a per-graph unkeyed side set. Design records:
[Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md).
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -149,28 +159,37 @@ The most architecturally advanced extraction — uses Y.js CRDTs for collaborati
```
ynodes: Y.Map<NodeLayoutMap> // nodeId → { pos, size, zIndex, bounds }
ylinks: Y.Map<Y.Map<...>> // linkId → link layout data
yreroutes: Y.Map<Y.Map<...>> // rerouteId → reroute layout data
yreroutes: Y.Map<Y.Map<...>> // rerouteId → { id, position }
```
**Update (2026-07-05):** The link-connectivity mirror (`ylinks`, `LinkData`,
`createLink`/`removeLink` mutations, `findLinksConnectedToNode`) was deleted
when link topology moved to `LinkStore` (PR #13436). LayoutStore now owns only
link/segment _geometry_ caches, and `RerouteData` carries `{ id, position }`
only — the write-only `parentId`/`linkIds` fields were removed.
### Write API
`useLayoutMutations()` (`src/renderer/core/layout/operations/layoutMutations.ts`) provides the mutation API:
- `moveNode(graphId, nodeId, pos)`
- `resizeNode(graphId, nodeId, size)`
- `setNodeZIndex(graphId, nodeId, zIndex)`
- `createLink(graphId, linkId, ...)`
- `removeLink(graphId, linkId)`
- `moveReroute(graphId, rerouteId, pos)`
- `moveNode(nodeId, pos)` / `batchMoveNodes(...)`
- `resizeNode(nodeId, size)`
- `setNodeZIndex(nodeId, zIndex)` / `bringNodeToFront(nodeId)`
- `createNode(nodeId, layout)` / `deleteNode(nodeId)`
- `createReroute(rerouteId, pos)` / `deleteReroute(rerouteId)` /
`moveReroute(rerouteId, pos, prevPos)`
(`createLink`/`removeLink` are gone — link topology is `LinkStore`'s concern.)
### The Scattered Access Problem
This composable is called at **module scope** in domain objects:
- `LLink.ts:24``const layoutMutations = useLayoutMutations()`
- `Reroute.ts` — same pattern
- `Reroute.ts:31` — `const layoutMutations = useLayoutMutations()`
- `LGraphNode.ts` — imported and called in methods
- `LLink.ts` no longer uses `useLayoutMutations` (its layout writes went away
with the `ylinks` mirror), but it still imports `layoutStore` and
`useLinkStore` at module scope
These module-scope calls create implicit dependencies on the Vue runtime and make the domain objects untestable without a full app context.
@@ -234,12 +253,14 @@ graph TD
Each store owns the identity scheme that fits its concern:
| Store | Key Format | Key Type | Type-Safe? |
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| Store | Key Format | Key Type | Type-Safe? |
| ---------------- | ----------------------------------------------------------- | ------------------ | ----------------- |
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| LinkStore | `` `${targetNodeId}:${targetSlot}` `` (root-scoped buckets) | Composite string | No |
| RerouteStore | `RerouteId` (root-scoped buckets) | branded number | Yes (`RerouteId`) |
`WidgetValueStore` already keys on a branded `WidgetId` string (`src/types/widgetId.ts`),
which carries its scope and survives renames at the store layer. The remaining
@@ -286,22 +307,26 @@ graph TD
subgraph Link["LLink"]
L_ext["Extracted:
- layout data → LayoutStore"]
- id, endpoints, type, parentId → LinkStore
(LLink._state IS the store entry;
fields are accessors over it)
- segment geometry → LayoutStore"]
L_rem["Remains on class:
- origin_id, target_id
- origin_slot, target_slot
- type, color, path
- color, path, _pos, _centreAngle
- data, _dragging
- disconnect(), resolve()"]
end
subgraph Reroute["Reroute"]
R_ext["Extracted:
- pos → LayoutStore"]
- pos → LayoutStore (partial mirror;
posInternal still truth)
- parentId, floating → RerouteStore
- linkIds, floatingLinkIds → derived
from links' parentId chains"]
R_rem["Remains on class:
- parentId, linkIds
- floatingLinkIds
- color, draw()
- posInternal (position truth)
- colour, draw()
- findSourceOutput()"]
end
@@ -346,15 +371,19 @@ graph TD
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | endpoints, type, parentId (LinkStore, via `_state` proxy); segment geometry (LayoutStore) | visual (color, path), drag state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Small — topology shipped (PR #13436); visual state and slot mirrors remain |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | parentId, floating (RerouteStore); pos (LayoutStore, partial mirror) | position truth (posInternal), visual, chain traversal | Position, RerouteChain ✅, RerouteVisual | Small — chain shipped (PR #13449); position ownership and visual remain |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
`RerouteChain` supersedes the earlier `RerouteLinks` component (ADR 0008
amendment, 2026-07-04): link membership is never stored — it is derived from
the links' `parentId` chains over `LinkStore`.
### Priority Order for Extraction
@@ -362,8 +391,11 @@ Based on existing progress and problem severity:
1. **Widget** — closest to done (value extraction complete, needs rendering/layout extraction)
2. **Node Position** — already in LayoutStore, needs branded ID and formal component type
3. **Link**small component set, high coupling pain
3. **Link** — ✅ topology shipped (LinkStore, PR #13436); slot mirrors
(`input.link`/`output.links`) and visual state remain
4. **Slot** — no extraction yet, but small and self-contained
5. **Reroute** — partially extracted, moderate complexity
(`SlotConnection` input side now answerable via LinkStore)
5. **Reroute** — ✅ chain shipped (RerouteStore, PR #13449); position
ownership and visual remain
6. **Group** — no extraction, but least coupled to other entities
7. **Subgraph** — not a separate entity kind; SubgraphStructure and SubgraphMeta become node components. Depends on Node and Link extraction first. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md)

View File

@@ -0,0 +1,118 @@
# Reroute Chain Store
Date: 2026-07-04
Status: Accepted (design review; follow-up to the
[link topology store](link-topology-store.md), PR #13436)
Design record for extracting reroute connectivity state into a dedicated
store per [ADR 0008](../adr/0008-entity-component-system.md). Amends the
`RerouteLinks` component described there.
## Decision 1: The chain is the single source of truth for membership
"Which links pass through reroute R" is encoded twice today: each link's
`parentId` chain (`link.parentId` names the terminal reroute;
`reroute.parentId` walks upstream), and per-reroute `linkIds` /
`floatingLinkIds` Sets maintained by hand at roughly ten write sites
(`LGraphNode.connect`, `LLink.disconnect`, `LGraph.addFloatingLink` /
`removeFloatingLink` / `createReroute`, subgraph unpack,
`SubgraphInput/Output.connect`, `LinkConnector`), with
`Reroute.validateLinks` repairing drift at configure time.
The chain is primary. Membership becomes a derived reverse index:
```
linksThrough(R) = { L : R ∈ chain(L.parentId) }
```
computed over the link store's topologies plus the reroute chain states,
cached, and invalidated on chain mutation. The `Reroute` class exposes
`linkIds` / `floatingLinkIds` as derived accessors. The membership write
sites and `validateLinks` are deleted.
Rejected: storing membership Sets in the store (the `LLink._state`
pattern applied to whole membership). It keeps one _stored_ copy but
preserves the domain-level redundancy — chain and membership can still
disagree, and every dual write site survives.
## Decision 2: Store shape and keying
`rerouteStore` holds per-reroute chain state objects, registered by
reference (the class reads through them, mirroring `LLink._state`):
```
RerouteChain { id, parentId?, floating? }
```
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `RerouteId`,
matching `widgetValueStore` and `linkStore` scoping. Owning-graph buckets
were evaluated and rejected for the link store (2026-07-04) and are
rejected here for the same reasons.
`RerouteId` is the domain key because runtime allocation is already
per-root-unique: `Subgraph.state` delegates to the root graph's state, so
all graphs increment one shared `lastRerouteId` counter.
## Decision 3: Load-time reroute-id dedup
Serialized workflows from older frontends or external tools can carry
colliding reroute ids across sibling subgraph definitions. Today this is
tolerated only because `graph.reroutes` is a per-graph map; a root-scoped
bucket would break on it, and the layout store's bare-`rerouteId` keying
already collides latently.
On configure, colliding subgraph reroute ids are rewritten to fresh ids
from the shared counter, patching that subgraph's `link.parentId` and
`reroute.parentId` references — the same repair the node-id and link-id
dedup passes already perform. Rewritten ids serialize back changed.
Rejected: a first-wins registration protocol (losers detached via an
ownership flag). That is the apparatus the target-keyed link store
redesign existed to delete.
## Decision 4: Registration returns the reactive proxy
The derived membership index requires observable chain mutation.
`BaseWidget` already establishes the pattern: the store bucket is a
`reactive(Map)`, registration inserts the raw state and returns the
value read back from the map — the reactive proxy — and the class holds
that proxy as `_state` (`BaseWidget.setNodeId`,
`widgetValueStore.registerWidget`). Every subsequent class write goes
through the proxy and is tracked.
`rerouteStore.registerReroute` follows this: it returns the proxy and
the `Reroute` class reads and writes chain state through it, so
`reroute.parentId` mutations are tracked with no action chokepoint.
`LLink` previously deviated — `registerLinkTopology` left `link._state`
raw, which is why `linkStore.updateEndpoint` must re-wrap with
`reactive()` before patching, and why bare `link.parentId` writes were
invisible to effects. This migration aligns it with the `BaseWidget`
pattern: link registration re-assigns `_state` to the store proxy,
making `link.parentId` writes tracked. `updateEndpoint` keeps its
`reactive()` wrap as a guard — the store is public API and may be
handed a raw topology object.
## Decision 5: Serialization
`SerialisableReroute.linkIds` remains in the wire format, emitted from
the derived membership in ascending link-id order. All runtime producers
append links in ascending id order and the serialization goldens hold
ascending arrays, so chain-consistent workflows round-trip byte-identical.
Workflows whose stored `linkIds` contradict their chains are repaired on
the next save (membership re-derived, stale ids dropped, order
normalized). A reroute that no chain reaches at all is dropped at load —
where `validateLinks` used to preserve it if its stored ids named live
links — because the chain is primary. No compatibility shim for such
files. `floatingLinkIds` stays unserialized, rebuilt at runtime as
before.
## Scope
This design covers chain state, derived membership, and the `LLink`
proxy retrofit from Decision 4 (a small self-contained change, done
first). Reroute visual state (`_colour`, badge) is out of scope. The
`Reroute.pos` class field still mirrors the layout store's position;
that pre-existing duplication is a separate concern, not addressed
here.

View File

@@ -11,6 +11,8 @@ import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import type { WidgetId } from '@/types/widgetId'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
@@ -19,6 +21,8 @@ import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.v
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -29,9 +33,8 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
nodeData: ReturnType<typeof nodeToNodeData>
widgetIds: readonly WidgetId[]
action: { widget: IBaseWidget; node: LGraphNode }
}
@@ -43,6 +46,8 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const widgetValueStore = useWidgetValueStore()
const linkStore = useLinkStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
@@ -54,49 +59,61 @@ provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
const resolvedInputs = useResolvedSelectedInputs()
const mappedSelections = computed((): WidgetEntry[] => {
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
function ensureSelectedWidgetState(
widgetId: WidgetId,
widget: IBaseWidget
): void {
if (widgetValueStore.getWidget(widgetId)) return
widgetValueStore.registerWidget(
widgetId,
{
type: widget.type,
value: widget.value,
options: widget.options,
label: widget.label,
serialize: widget.serialize,
disabled: widget.disabled
},
deriveWidgetRenderState(widget)
)
}
function isWidgetInputLinked(node: LGraphNode, widgetName: string): boolean {
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widgetName)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
}
const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = node.id
ensureSelectedWidgetState(widgetId, widget)
const fullNodeData = nodeToNodeData(node, widgetId)
if (isWidgetInputLinked(node, widget.name)) return []
return [
{
key: widgetId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
nodeData: fullNodeData,
widgetIds: [widgetId],
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode) {
function getDropIndicator(node: LGraphNode, id: WidgetId) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const stringValue = extractWidgetStringValue(
widgetValueStore.getWidget(id)?.value
)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
@@ -120,8 +137,8 @@ function getDropIndicator(node: LGraphNode) {
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
function nodeToNodeData(node: LGraphNode, id: WidgetId) {
const dropIndicator = getDropIndicator(node, id)
const nodeData = extractVueNodeData(node)
return {
@@ -148,7 +165,13 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
v-for="{
key,
persistedHeight,
nodeData,
widgetIds,
action
} in mappedSelections"
:key
:class="
cn(
@@ -235,6 +258,7 @@ defineExpose({ handleDragDrop })
>
<NodeWidgets
:node-data
:widget-ids
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',

View File

@@ -1,9 +1,13 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { getDomWidgetZIndex } from './domWidgetZIndex'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('getDomWidgetZIndex', () => {
it('follows graph node ordering when node.order is stale', () => {
const graph = new LGraph()

View File

@@ -12,8 +12,6 @@ import {
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
@@ -45,12 +43,12 @@ const {
isDraggable = false,
hiddenFavoriteIndicator = false,
showNodeName = false,
parents = [],
host,
enableEmptyState = false,
tooltip
} = defineProps<{
label?: string
parents?: SubgraphNode[]
host?: SubgraphNode
node?: LGraphNode
widgets: { widget: IBaseWidget; node: LGraphNode }[]
showLocateButton?: boolean
@@ -145,31 +143,6 @@ const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
): boolean {
const source = widgetPromotedSource(widgetNode, widget)
return parents.some((parent) => {
if (source) {
const widgetNodeId = widgetNode.id
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? source.nodeId
: widgetNodeId
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: source.widgetName
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: widgetNode.id,
sourceWidgetName: widget.name
})
})
}
const isEmpty = computed(() => widgets.value.length === 0)
const displayLabel = computed(
@@ -256,7 +229,10 @@ function clearWidgetErrors(
source.sourceWidgetName,
source.sourceWidgetName,
value,
options
{
min: source.sourceWidget.options?.min,
max: source.sourceWidget.options?.max
}
)
}
@@ -401,8 +377,7 @@ defineExpose({
:is-draggable="isDraggable"
:hidden-favorite-indicator="hiddenFavoriteIndicator"
:show-node-name="showNodeName"
:parents="parents"
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
:host="host"
@update:widget-value="handleWidgetValueUpdate(node, widget, $event)"
@reset-to-default="handleWidgetReset(node, widget, $event)"
/>

View File

@@ -33,7 +33,7 @@ const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
}
const SectionWidgetsStub = {
props: ['widgets', 'node', 'parents'],
props: ['widgets', 'node', 'host'],
setup(props: Record<string, unknown>) {
captured.rows = props.widgets as {
node: LGraphNode

View File

@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
@@ -83,13 +82,11 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
({ node: interiorNode, widget }) =>
!isWidgetPromotedOnSubgraphNode(node, {
sourceNodeId: interiorNode.id,
sourceWidgetName: getWidgetName(widget)
sourceWidgetName: widget.name
})
)
})
const parents = computed<SubgraphNode[]>(() => [node])
const searchedWidgetsList = shallowRef<NodeWidgetsList>(widgetsList.value)
const isSearching = ref(false)
@@ -140,7 +137,7 @@ const label = computed(() => {
:collapse="firstSectionCollapsed && !isSearching"
:node
:label
:parents
:host="node"
:widgets="searchedWidgetsList"
:is-draggable="!isSearching"
:enable-empty-state="isSearching"
@@ -164,7 +161,7 @@ const label = computed(() => {
ref="advancedInputsSectionRef"
v-model:collapse="advancedInputsCollapsed"
:label="t('rightSidePanel.advancedInputs')"
:parents="parents"
:host="node"
:widgets="advancedInputsWidgets"
show-node-name
class="border-b border-interface-stroke"

View File

@@ -8,18 +8,21 @@ import { h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { promoteWidget } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import WidgetActions from './WidgetActions.vue'
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
mockGetInputSpecForWidget: vi.fn()
}))
const { mockGetInputSpecForWidget, mockIsFavorited, mockToggleFavorite } =
vi.hoisted(() => ({
mockGetInputSpecForWidget: vi.fn(),
mockIsFavorited: vi.fn(),
mockToggleFavorite: vi.fn()
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
demoteWidget: vi.fn(),
promoteWidget: vi.fn(),
isLinkedPromotion: vi.fn(() => false)
promoteWidget: vi.fn()
}))
vi.mock('@/stores/nodeDefStore', () => ({
@@ -36,8 +39,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
useFavoritedWidgetsStore: () => ({
isFavorited: vi.fn().mockReturnValue(false),
toggleFavorite: vi.fn()
isFavorited: mockIsFavorited,
toggleFavorite: mockToggleFavorite
})
}))
@@ -62,7 +65,6 @@ const i18n = createI18n({
enterNewName: 'Enter new name'
},
rightSidePanel: {
hideInput: 'Hide input',
showInput: 'Show input',
addFavorite: 'Favorite',
removeFavorite: 'Unfavorite',
@@ -76,6 +78,7 @@ describe('WidgetActions', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
mockIsFavorited.mockReturnValue(false)
mockGetInputSpecForWidget.mockReturnValue({
type: 'INT',
default: 42
@@ -205,4 +208,53 @@ describe('WidgetActions', () => {
expect(onResetToDefault).toHaveBeenCalledWith('option1')
})
it('promotes the widget into the host when "Show input" is clicked', async () => {
const widget = createMockWidget()
const node = createMockNode()
const host = fromAny<SubgraphNode, unknown>({ id: 2 })
const { user } = renderWidgetActions(widget, node, { host })
await user.click(screen.getByRole('button', { name: /Show input/ }))
expect(promoteWidget).toHaveBeenCalledWith(node, widget, [host])
})
it('does not offer "Show input" without a host', () => {
renderWidgetActions(createMockWidget(), createMockNode())
expect(
screen.queryByRole('button', { name: /Show input/ })
).not.toBeInTheDocument()
})
it('does not offer "Show input" when the host input is already linked', () => {
const widget = createMockWidget()
const node = fromAny<LGraphNode, unknown>({
id: 1,
type: 'TestNode',
rootGraph: { id: 'graph-test' },
isSubgraphNode: () => true,
getSlotFromWidget: () => ({ widgetId: 'graph-test:1:test_widget' })
})
const host = fromAny<SubgraphNode, unknown>({ id: 2 })
renderWidgetActions(widget, node, { host })
expect(
screen.queryByRole('button', { name: /Show input/ })
).not.toBeInTheDocument()
})
it('toggles the favorite for the host node itself', async () => {
const widget = createMockWidget()
const node = createMockNode()
const { user } = renderWidgetActions(widget, node)
await user.click(screen.getByRole('button', { name: /Favorite/ }))
expect(mockToggleFavorite).toHaveBeenCalledWith(node, 'test_widget')
})
})

View File

@@ -5,33 +5,21 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import {
demotePromotedInput,
demoteWidget,
isLinkedPromotion,
promoteWidget
} from '@/core/graph/subgraph/promotionUtils'
import { inputForWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { promoteWidget } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
const {
widget,
node,
parents = [],
isShownOnParents = false
} = defineProps<{
const { widget, node, host } = defineProps<{
widget: IBaseWidget
node: LGraphNode
parents?: SubgraphNode[]
isShownOnParents?: boolean
host?: SubgraphNode
}>()
const emit = defineEmits<{
@@ -40,24 +28,17 @@ const emit = defineEmits<{
const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode()) return false
const source = widgetPromotedSource(node, widget)
if (!source) return false
return isLinkedPromotion(node, source.nodeId, source.widgetName)
return inputForWidget(node, widget)?.widgetId != null
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
isShownOnParents && hasParents.value ? parents[0] : node
)
const canShowInput = computed(() => host != null && !isLinked.value)
const isFavorited = computed(() =>
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
favoritedWidgetsStore.isFavorited(node, widget.name)
)
const inputSpec = computed(() =>
@@ -85,33 +66,13 @@ async function handleRename() {
if (newLabel !== null) label.value = newLabel
}
function handleHideInput() {
if (!parents?.length) return
const source = widgetPromotedSource(node, widget)
if (source) {
const currentNodeId = node.id
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id) ? source.nodeId : currentNodeId
demotePromotedInput(parent, {
sourceNodeId,
sourceWidgetName: source.widgetName
})
}
canvasStore.canvas?.setDirty(true, true)
} else {
demoteWidget(node, widget, parents)
}
}
function handleShowInput() {
if (!parents?.length) return
promoteWidget(node, widget, parents)
if (!host) return
promoteWidget(node, widget, [host])
}
function handleToggleFavorite() {
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
favoritedWidgetsStore.toggleFavorite(node, widget.name)
}
function handleResetToDefault() {
@@ -143,26 +104,19 @@ function handleResetToDefault() {
</Button>
<Button
v-if="canToggleVisibility"
v-if="canShowInput"
variant="textonly"
size="unset"
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
@click="
() => {
if (isShownOnParents) handleHideInput()
else handleShowInput()
handleShowInput()
close()
}
"
>
<template v-if="isShownOnParents">
<i class="icon-[lucide--eye-off] size-4" />
<span>{{ t('rightSidePanel.hideInput') }}</span>
</template>
<template v-else>
<i class="icon-[lucide--eye] size-4" />
<span>{{ t('rightSidePanel.showInput') }}</span>
</template>
<i class="icon-[lucide--eye] size-4" />
<span>{{ t('rightSidePanel.showInput') }}</span>
</Button>
<Button

View File

@@ -2,14 +2,18 @@ import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -204,5 +208,51 @@ describe('WidgetItem', () => {
expect(stub.value).toBe('model_a.safetensors')
})
it('passes null from widget state to the widget component', () => {
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: null,
options: {}
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.value).toBe('null')
})
it('updates disabled options when the widget input is linked', async () => {
const inputs: INodeInputSlot[] = [
{
name: 'seed',
type: 'INT',
link: null,
boundingRect: [0, 0, 0, 0],
widget: { name: 'seed' }
}
]
const node = createMockNode(
fromAny<Partial<LGraphNode>, unknown>({ inputs })
)
const widget = createMockWidget({ name: 'seed', options: {} })
const { container } = renderWidgetItem(widget, node)
expect(getStubWidget(container).options.disabled).toBeUndefined()
useLinkStore().registerLink('test-graph-id', {
id: toLinkId(1),
originNodeId: toNodeId(2),
originSlot: 0,
targetNodeId: node.id,
targetSlot: 0,
type: 'INT'
})
await nextTick()
expect(getStubWidget(container).options.disabled).toBe(true)
})
})
})

View File

@@ -3,8 +3,6 @@ import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -15,13 +13,18 @@ import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useLinkStore } from '@/stores/linkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedWidget,
WidgetValue as SimplifiedWidgetValue
} from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -37,8 +40,7 @@ const {
hiddenFavoriteIndicator = false,
hiddenWidgetActions = false,
showNodeName = false,
parents = [],
isShownOnParents = false
host
} = defineProps<{
widget: IBaseWidget
node: LGraphNode
@@ -46,8 +48,7 @@ const {
hiddenFavoriteIndicator?: boolean
hiddenWidgetActions?: boolean
showNodeName?: boolean
parents?: SubgraphNode[]
isShownOnParents?: boolean
host?: SubgraphNode
}>()
const emit = defineEmits<{
@@ -61,6 +62,7 @@ const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const widgetValueStore = useWidgetValueStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const linkStore = useLinkStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
@@ -69,16 +71,14 @@ const widgetComponent = computed(() => {
})
const isLinked = computed(() => {
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widget.name)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
})
const simplifiedWidget = computed((): SimplifiedWidget => {
const graphId = node.graph?.rootGraph?.id
const graphId = node.graph?.rootGraph.id
const bareNodeId = stripGraphPrefix(node.id)
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
@@ -93,7 +93,9 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
return {
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
value: (widgetState
? widgetState.value
: widget.value) as SimplifiedWidgetValue,
label: widgetState?.label ?? widget.label,
options: { ...baseOptions, disabled },
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
@@ -111,10 +113,7 @@ const displayNodeName = computed((): string | null => {
})
})
const hasParents = computed(() => parents?.length > 0)
const favoriteNode = computed(() =>
isShownOnParents && hasParents.value ? parents[0] : node
)
const hasHost = computed(() => host != null)
const widgetValue = computed({
get: () => widget.value,
@@ -134,7 +133,7 @@ const displayLabel = customRef((track, trigger) => {
const trimmedLabel = newValue.trim()
const success = renameWidget(widget, node, trimmedLabel, parents)
const success = renameWidget(widget, node, trimmedLabel)
if (success) {
canvasStore.canvas?.setDirty(true)
@@ -176,7 +175,7 @@ const displayLabel = customRef((track, trigger) => {
/>
<span
v-if="(showNodeName || hasParents) && displayNodeName"
v-if="(showNodeName || hasHost) && displayNodeName"
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
>
{{ displayNodeName }}
@@ -189,8 +188,7 @@ const displayLabel = customRef((track, trigger) => {
v-model:label="displayLabel"
:widget="widget"
:node="node"
:parents="parents"
:is-shown-on-parents="isShownOnParents"
:host="host"
@reset-to-default="emit('resetToDefault', $event)"
/>
</div>
@@ -199,7 +197,7 @@ const displayLabel = customRef((track, trigger) => {
<div
v-if="
!hiddenFavoriteIndicator &&
favoritedWidgetsStore.isFavorited(favoriteNode, widget.name)
favoritedWidgetsStore.isFavorited(node, widget.name)
"
class="pointer-events-none relative z-2"
>

View File

@@ -7,10 +7,8 @@ import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import {
demotePromotedInput,
demoteWidget,
getPromotableWidgets,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected,
@@ -23,7 +21,10 @@ import {
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type {
INodeInputSlot,
ISubgraphInput
} from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -32,7 +33,6 @@ import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { cn } from '@comfyorg/tailwind-utils'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
@@ -40,7 +40,7 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
input: INodeInputSlot
input: INodeInputSlot & Partial<ISubgraphInput>
widget: IBaseWidget
}
type PreviewRow = {
@@ -248,14 +248,7 @@ function rowDisplayName(row: ActiveRow): string {
}
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === UNASSIGNED_NODE_ID) return true
const source = promotedRowSource(row)
return (
!!activeNode.value &&
!!source &&
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
)
return row.kind === 'promoted' && row.input.widgetId != null
}
function promotedRowKey(row: PromotedRow): string {
@@ -276,12 +269,11 @@ function demoteRow(row: ActiveRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
if (row.kind === 'promoted') {
const source = promotedRowSource(row)
if (source) {
demotePromotedInput(subgraphNode, {
sourceNodeId: source.nodeId,
sourceWidgetName: source.widgetName
})
const linkedInput = row.input._subgraphSlot
if (linkedInput) {
const inputIndex = subgraphNode.inputs.indexOf(row.input)
if (subgraphNode.isInputConnected(inputIndex)) linkedInput.disconnect()
else subgraphNode.subgraph.removeInput(linkedInput)
}
refreshPromotedWidgetRendering()
return

View File

@@ -67,10 +67,10 @@ describe('Node Reactivity', () => {
const store = useWidgetValueStore()
const onValueChange = vi.fn()
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
const upstream = new LGraphNode('upstream')
upstream.addOutput('out', 'INT')
graph.add(upstream)
upstream.connect(0, node, 0)
await nextTick()
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
@@ -87,7 +87,7 @@ describe('Node Reactivity', () => {
})
})
describe('Widget slotMetadata reactivity on link disconnect', () => {
describe('Widget input link reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -96,10 +96,8 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
graph.add(node)
@@ -112,81 +110,14 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
return { graph, node, upstream, linkId: link.id }
}
it('sets slotMetadata.linked to true when input has a link', () => {
it('exposes linked widget input slots through Vue node inputs', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
expect(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
@@ -205,15 +136,11 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
it('registers promoted widget render state separately from value state', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
@@ -229,39 +156,21 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
useGraphNodeManager(graph)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
})
const id = widgetId(graph.id, subgraphNode.id, 'value')
const store = useWidgetValueStore()
const valueState = store.getWidget(id)
const renderState = store.getWidgetRenderState(id)
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
expect(valueState?.name).toBe('value')
expect(valueState?.value).toBe('hello')
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
})
})
@@ -368,15 +277,13 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
useGraphNodeManager(graph)
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
const id = widgetId(graph.id, subgraphNodeB.id, 'b_input')
const state = useWidgetValueStore().getWidget(id)
expect(state?.type).toBe('combo')
expect(subgraphNodeB.widgets[0]?.widgetId).toBe(id)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
@@ -405,27 +312,23 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
useGraphNodeManager(graph)
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
const ids = subgraphNode.widgets.map((widget) => widget.widgetId)
expect(ids).toStrictEqual([
widgetId(graph.id, subgraphNode.id, 'first_seed'),
widgetId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
])
expect(ids[0]).not.toBe(ids[1])
})
})
describe('Promoted widget sourceExecutionId', () => {
describe('Promoted widget render state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
it('registers plain render metadata for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
@@ -451,22 +354,21 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
useGraphNodeManager(graph)
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, subgraphNode.id, 'ckpt_input')
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
`${subgraphNode.id}:${interiorNode.id}`
)
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
it('does not set sourceExecutionId for non-promoted widgets', () => {
it('registers plain render metadata for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
@@ -474,12 +376,14 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
useGraphNodeManager(graph)
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, node.id, 'steps')
)
expect(renderState).toBeDefined()
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
})

View File

@@ -1,14 +1,6 @@
/**
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -19,86 +11,16 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
LGraphBadge,
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam,
SubgraphNode
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the host
* subgraph node. Retained for source-scoped validation errors.
*/
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name` is
* the host input slot name and the source widget name can differ.
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
}
export interface VueNodeData {
executing: boolean
@@ -108,7 +30,6 @@ export interface VueNodeData {
title: string
type: string
apiNode?: boolean
badges?: Badges
bgcolor?: string
color?: string
flags?: {
@@ -124,260 +45,24 @@ export interface VueNodeData {
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
}
}
interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
spec?: InputSpec
}
function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: NodeExecutionId
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (!source) return undefined
ensurePromotedHostWidgetState(
source.input.widgetId,
source.input,
source.sourceWidget
)
return {
controlWidget: getControlWidget(source.sourceWidget),
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
sourceExecutionId: source.sourceExecutionId,
sourceWidgetName: source.sourceWidgetName
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
return function (widget) {
try {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
}
}
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
function makeReactiveNodeArrays(node: LGraphNode): {
inputs: INodeInputSlot[]
outputs: INodeOutputSlot[]
} {
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
'widgets'
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
@@ -406,6 +91,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
@@ -417,6 +103,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
@@ -429,19 +116,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return { inputs: reactiveInputs, outputs: reactiveOutputs }
}
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
export function extractVueNodeData(node: LGraphNode): VueNodeData {
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
const { inputs, outputs } = makeReactiveNodeArrays(node)
const nodeType =
node.type ||
node.constructor?.comfyClass ||
@@ -449,9 +133,6 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
@@ -459,14 +140,12 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
executing: false,
subgraphId,
apiNode,
badges,
apiNode: node.constructor?.nodeData?.api_node ?? false,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
inputs,
outputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -477,39 +156,17 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<NodeId, LGraphNode>()
const refreshNodeSlots = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
}
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const getNode = (id: NodeId): LGraphNode | undefined => nodeRefs.get(id)
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
@@ -517,76 +174,49 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
}
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
@@ -603,16 +233,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) => {
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
deleteNode(id)
dropNodeReferences(id)
originalCallback?.(node)
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
@@ -620,7 +246,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
@@ -630,19 +255,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
@@ -662,7 +284,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
[K in Extract<
LGraphTriggerAction,
'node:property:changed' | 'node:slot-label:changed'
>]: (event: LGraphTriggerParam<K>) => void
} = {
'node:property:changed': (propertyEvent) => {
const nodeId = toNodeId(propertyEvent.nodeId)
@@ -751,38 +376,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
showAdvanced: Boolean(propertyEvent.newValue)
})
break
case 'badges':
vueNodeData.set(nodeId, {
...currentData,
badges: propertyEvent.newValue as Badges
})
break
}
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
const nodeId = toNodeId(slotLabelEvent.nodeId)
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}
@@ -791,22 +398,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
case 'node:property:changed':
triggerHandlers['node:property:changed'](event)
break
case 'node:slot-errors:changed':
triggerHandlers['node:slot-errors:changed'](event)
break
case 'node:slot-links:changed':
triggerHandlers['node:slot-links:changed'](event)
break
case 'node:slot-label:changed':
triggerHandlers['node:slot-label:changed'](event)
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
return createCleanupFunction(
@@ -817,10 +416,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {

View File

@@ -10,7 +10,6 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { app as comfyApp } from '@/scripts/app'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -39,25 +38,7 @@ function useVueNodeLifecycleIndividual() {
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of activeGraph.reroutes.values()) {
const [x, y] = reroute.pos
const parent = reroute.parentId ?? undefined
const linkIds = Array.from(reroute.linkIds)
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
}
// Seed existing links into the Layout Store (topology only)
for (const link of activeGraph._links.values()) {
if (
link.origin_id === UNASSIGNED_NODE_ID ||
link.target_id === UNASSIGNED_NODE_ID
)
continue
layoutMutations.createLink(
link.id,
link.origin_id,
link.origin_slot,
link.target_id,
link.target_slot
)
layoutMutations.createReroute(reroute.id, { x, y })
}
// Start sync AFTER seeding so bootstrap operations don't trigger

View File

@@ -1,47 +1,27 @@
import _ from 'es-toolkit/compat'
import { computed, onMounted, watch } from 'vue'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import { BadgePosition, LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import {
bumpSubgraphCreditsRevision,
startBadgeSystem
} from '@/systems/badgeSystem'
import { resolveNode } from '@/utils/litegraphUtil'
/**
* Add LGraphBadge to LGraphNode based on settings.
*
* Following badges are added:
* - Node ID badge
* - Node source badge
* - Node life cycle badge
* - API node credits badge
* Bootstraps the badge system: starts it against the live root graph,
* forwards the subgraph structure events its store sources cannot
* observe, and keeps the legacy canvas redrawing when badge sources
* change. Dynamic-pricing recalculation wiring stays here because it
* drives price evaluation, not badge storage.
*/
export const useNodeBadge = () => {
const settingStore = useSettingStore()
const extensionStore = useExtensionStore()
const colorPaletteStore = useColorPaletteStore()
const priceBadge = usePriceBadge()
const nodeSourceBadgeMode = computed(
() =>
settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
)
const nodeIdBadgeMode = computed(
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
)
const nodeLifeCycleBadgeMode = computed(
() =>
settingStore.get(
'Comfy.NodeBadge.NodeLifeCycleBadgeMode'
) as NodeBadgeMode
)
const showApiPricingBadge = computed(() =>
settingStore.get('Comfy.NodeBadge.ShowApiPricing')
@@ -49,9 +29,9 @@ export const useNodeBadge = () => {
watch(
[
nodeSourceBadgeMode,
nodeIdBadgeMode,
nodeLifeCycleBadgeMode,
() => settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode'),
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode'),
() => settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode'),
showApiPricingBadge
],
() => {
@@ -59,21 +39,9 @@ export const useNodeBadge = () => {
}
)
const nodeDefStore = useNodeDefStore()
function badgeTextVisible(
nodeDef: ComfyNodeDefImpl | null,
badgeMode: NodeBadgeMode
): boolean {
return !(
badgeMode === NodeBadgeMode.None ||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
)
}
onMounted(() => {
if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return
// TODO: Fix the composables and watchers being setup in onMounted
const nodePricing = useNodePricing()
watch(
@@ -84,140 +52,86 @@ export const useNodeBadge = () => {
}
)
function wirePricingRecalculation(node: LGraphNode): void {
const pricingConfig = nodePricing.getNodePricingConfig(node)
const hasDynamicPricing =
!!pricingConfig &&
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
if (!hasDynamicPricing) return
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
node.constructor.nodeData?.name ?? ''
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
widgetNames: relevantWidgetNames,
triggerCanvasRedraw: true
})
// Installs the widget listeners; the returned value is unused.
computedWithWidgetWatch(() => 0)
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
const inputGroupPrefixes = pricingConfig?.depends_on?.input_groups ?? []
if (relevantInputs.length === 0 && inputGroupPrefixes.length === 0) return
const originalOnConnectionsChange = node.onConnectionsChange
node.onConnectionsChange = function (
type,
slotIndex,
isConnected,
link,
ioSlot
) {
originalOnConnectionsChange?.call(
this,
type,
slotIndex,
isConnected,
link,
ioSlot
)
const inputName = ioSlot?.name
if (!inputName) return
const isRelevantInput =
relevantInputs.includes(inputName) ||
inputGroupPrefixes.some((prefix) =>
inputName.startsWith(prefix + '.')
)
if (isRelevantInput) {
nodePricing.triggerPriceRecalculation(node)
}
}
}
extensionStore.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
node.badgePosition = BadgePosition.TopRight
const badge = computed(() => {
const nodeDef = nodeDefStore.fromLGraphNode(node)
return new LGraphBadge({
text: _.truncate(
[
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
? `#${node.id}`
: '',
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
? (nodeDef?.nodeLifeCycleBadgeText ?? '')
: '',
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
? (nodeDef?.nodeSource?.badgeText ?? '')
: ''
]
.filter((s) => s.length > 0)
.join(' '),
{
length: 31
}
),
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_BG_COLOR
})
})
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
const pricingConfig = nodePricing.getNodePricingConfig(node)
const hasDynamicPricing =
!!pricingConfig &&
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
// (We no longer rely on it to hold the current badge value.)
if (hasDynamicPricing) {
// For dynamic pricing nodes, use computed that watches widget changes
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
node.constructor.nodeData?.name
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
widgetNames: relevantWidgetNames,
triggerCanvasRedraw: true
})
// Ensure watchers are installed; ignore the returned value.
// (This call is what registers the widget listeners in most implementations.)
computedWithWidgetWatch(() => 0)
// Hook into connection changes to trigger price recalculation
// This handles both connect and disconnect in VueNodes mode
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
const inputGroupPrefixes =
pricingConfig?.depends_on?.input_groups ?? []
const hasRelevantInputs =
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
if (hasRelevantInputs) {
const originalOnConnectionsChange = node.onConnectionsChange
node.onConnectionsChange = function (
type,
slotIndex,
isConnected,
link,
ioSlot
) {
originalOnConnectionsChange?.call(
this,
type,
slotIndex,
isConnected,
link,
ioSlot
)
// Only trigger if this input affects pricing
const inputName = ioSlot?.name
if (!inputName) return
const isRelevantInput =
relevantInputs.includes(inputName) ||
inputGroupPrefixes.some((prefix) =>
inputName.startsWith(prefix + '.')
)
if (isRelevantInput) {
nodePricing.triggerPriceRecalculation(node)
}
}
}
}
let lastLabel = nodePricing.getNodeDisplayPrice(node)
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
const creditsBadgeGetter: () => LGraphBadge = () => {
const label = nodePricing.getNodeDisplayPrice(node)
if (label !== lastLabel) {
lastLabel = label
lastBadge = priceBadge.getCreditsBadge(label)
}
return lastBadge
}
node.badges.push(creditsBadgeGetter)
wirePricingRecalculation(node)
}
},
init() {
startBadgeSystem({
resolveGraphId: () => app.rootGraph.id,
resolveNode: (nodeId) => resolveNode(nodeId, app.rootGraph)
})
app.canvas.canvas.addEventListener<'litegraph:set-graph'>(
'litegraph:set-graph',
() => {
for (const node of app.canvas.graph?.nodes ?? [])
priceBadge.updateSubgraphCredits(node)
bumpSubgraphCreditsRevision()
app.canvas?.setDirty(true, true)
}
)
app.canvas.canvas.addEventListener<'subgraph-converted'>(
'subgraph-converted',
(e) => priceBadge.updateSubgraphCredits(e.detail.subgraphNode)
() => {
bumpSubgraphCreditsRevision()
}
)
},
afterConfigureGraph() {
for (const node of app.canvas.graph?.nodes ?? [])
priceBadge.updateSubgraphCredits(node)
bumpSubgraphCreditsRevision()
}
})
})

View File

@@ -87,6 +87,7 @@ function createMockNodeWithPriceBadge(
return Object.assign(baseNode, {
widgets: mockWidgets,
inputs: mockInputs,
isInputConnected: (slot: number) => mockInputs[slot]?.link != null,
constructor: {
nodeData: {
name: nodeTypeName,
@@ -119,6 +120,7 @@ function createMockNode(
return Object.assign(baseNode, {
widgets,
inputs,
isInputConnected: (slot: number) => inputs[slot]?.link != null,
constructor: { nodeData }
})
}
@@ -173,6 +175,26 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.05))
})
it('caches per signature so base and override reads both settle', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
'TestSignatureNode',
priceBadge('{"type":"text","text": widgets.prompt}', [
{ name: 'prompt', type: 'STRING' }
]),
[{ name: 'prompt', value: 'inner' }]
)
const overrides = new Map([['prompt', 'outer']])
getNodeDisplayPrice(node)
getNodeDisplayPrice(node, overrides)
await new Promise((r) => setTimeout(r, 50))
expect(getNodeDisplayPrice(node)).toBe('inner')
expect(getNodeDisplayPrice(node, overrides)).toBe('outer')
expect(getNodeDisplayPrice(node)).toBe('inner')
})
it('should handle FLOAT widget as number', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(

View File

@@ -251,8 +251,9 @@ const buildJsonataContext = (
const inputs: Record<string, { connected: boolean }> = {}
for (const name of rule.depends_on.inputs) {
const slot = node.inputs?.find((x: INodeInputSlot) => x.name === name)
inputs[name] = { connected: slot?.link != null }
const index =
node.inputs?.findIndex((x: INodeInputSlot) => x.name === name) ?? -1
inputs[name] = { connected: index !== -1 && node.isInputConnected(index) }
}
// Count connected inputs per autogrow group
@@ -261,8 +262,8 @@ const buildJsonataContext = (
const prefix = groupName + '.'
inputGroups[groupName] =
node.inputs?.filter(
(inp: INodeInputSlot) =>
inp.name?.startsWith(prefix) && inp.link != null
(inp: INodeInputSlot, index: number) =>
inp.name?.startsWith(prefix) && node.isInputConnected(index)
).length ?? 0
}
@@ -469,21 +470,40 @@ const getNodeRevisionRef = (nodeId: NodeId): Ref<number> => {
}
// WeakMaps avoid memory leaks when nodes are removed.
type CacheEntry = { sig: string; label: string }
type InflightEntry = { sig: string; promise: Promise<void> }
const cache = new WeakMap<LGraphNode, CacheEntry>()
const desiredSig = new WeakMap<LGraphNode, string>()
// Labels are cached per signature: a node can be read concurrently under
// several signatures (its own widget values, and a SubgraphNode wrapper's
// promoted overrides), and a single-slot cache would make those readers
// evict each other's entry and re-schedule forever.
const MAX_CACHED_SIGNATURES = 8
const cache = new WeakMap<LGraphNode, Map<string, string>>()
const inflight = new WeakMap<LGraphNode, InflightEntry>()
function nodeSigCache(node: LGraphNode): Map<string, string> {
const existing = cache.get(node)
if (existing) return existing
const next = new Map<string, string>()
cache.set(node, next)
return next
}
function cacheLabel(node: LGraphNode, sig: string, label: string): void {
const sigCache = nodeSigCache(node)
sigCache.delete(sig)
sigCache.set(sig, label)
for (const oldest of sigCache.keys()) {
if (sigCache.size <= MAX_CACHED_SIGNATURES) break
sigCache.delete(oldest)
}
}
const scheduleEvaluation = (
node: LGraphNode,
rule: CompiledJsonataPricingRule,
ctx: JsonataEvalContext,
sig: string
) => {
desiredSig.set(node, sig)
const running = inflight.get(node)
if (running && running.sig === sig) return
@@ -491,19 +511,11 @@ const scheduleEvaluation = (
const promise = Promise.resolve(rule._compiled.evaluate(ctx))
.then((res) => {
const label = formatPricingResult(res)
// Ignore stale results: if the node changed while we were evaluating,
// desiredSig will no longer match.
if (desiredSig.get(node) !== sig) return
cache.set(node, { sig, label })
cacheLabel(node, sig, formatPricingResult(res))
})
.catch(() => {
// Cache empty to avoid retry-spam for same signature
if (desiredSig.get(node) === sig) {
cache.set(node, { sig, label: '' })
}
cacheLabel(node, sig, '')
})
.finally(() => {
const cur = inflight.get(node)
@@ -574,15 +586,15 @@ export const useNodePricing = () => {
const ctx = buildJsonataContext(node, rule, widgetOverrides)
const sig = buildSignature(ctx, rule)
const cached = cache.get(node)
if (cached && cached.sig === sig) {
return cached.label
}
const sigCache = cache.get(node)
const hit = sigCache?.get(sig)
if (hit !== undefined) return hit
// Cache miss: start async evaluation.
// Return last-known label (if any) to avoid flicker; otherwise return empty.
// Return the last-known label (if any) to avoid flicker.
scheduleEvaluation(node, rule, ctx, sig)
return cached?.label ?? ''
const labels = [...(sigCache?.values() ?? [])]
return labels.at(-1) ?? ''
}
/**

View File

@@ -1,106 +0,0 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
const getNodeDisplayPrice = vi.fn(
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
String(overrides?.get('prompt') ?? 'missing override')
)
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({ getNodeDisplayPrice })
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
light_theme: false,
colors: { litegraph_base: {} }
}
})
}))
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
const mockNode = new LGraphNode('mock node')
mockNode.badges = [getCreditsBadge('$0.05/Run')]
function getBadgeText(node: LGraphNode): string {
const badge = node.badges[0]
return (typeof badge === 'function' ? badge() : badge).text
}
describe('subgraph pricing', () => {
subgraphTest(
'should not display badge for subgraphs without API nodes',
({ subgraphWithNode }) => {
const { subgraphNode } = subgraphWithNode
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(0)
}
)
subgraphTest(
'should return the price of a single contained API node',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
subgraph.add(mockNode)
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(1)
expect(getBadgeText(subgraphNode)).toBe('$0.05/Run')
}
)
subgraphTest(
'should return the number of api nodes if more than one exists',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
for (let i = 0; i < 5; i++) subgraph.add(mockNode)
updateSubgraphCredits(subgraphNode)
expect(subgraphNode.badges.length).toBe(1)
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
subgraphTest(
'uses promoted widget override from any matching internal link',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
const apiNode = new ApiNode('api node')
apiNode.badges = [getCreditsBadge('$0.05/Run')]
const apiInput = apiNode.addInput('prompt', 'STRING')
apiInput.widget = { name: 'prompt' }
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
const decoyNode = new LGraphNode('decoy node')
const decoyInput = decoyNode.addInput('prompt', 'STRING')
decoyInput.widget = { name: 'prompt' }
decoyNode.addWidget(
'string',
'prompt',
'decoy value',
() => undefined,
{}
)
subgraph.add(decoyNode)
subgraph.add(apiNode)
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
subgraphNode._internalConfigureAfterSlots()
const inputWidgetId = subgraphNode.inputs[0].widgetId
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
updateSubgraphCredits(subgraphNode)
expect(getBadgeText(subgraphNode)).toBe('outer value')
}
)
})

View File

@@ -1,152 +0,0 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: SubgraphInput
}
const componentIconSvg = new Image()
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 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
graph.trigger('node:property:changed', {
type: 'node:property:changed',
nodeId: node.id,
property: 'badges',
oldValue: node.badges,
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: (LGraphBadge | (() => LGraphBadge))[] = []
for (const node of graph.nodes) {
badges.push(
...(node.isSubgraphNode()
? collectCreditsBadges(node.subgraph, visited)
: node.badges.filter((b) => isCreditsBadge(b)))
)
}
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
}
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
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
if (!input.widgetId) continue
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
const link = wrapper.subgraph.getLink(linkId)
if (link?.target_id !== innerNode.id) continue
const targetInput = innerNode.inputs[link.target_slot]
const widgetName = targetInput?.widget?.name
if (!widgetName) continue
overrides.set(
widgetName,
useWidgetValueStore().getWidget(input.widgetId)?.value
)
}
}
return overrides
}
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {
const badgeInstance = typeof badge === 'function' ? badge() : badge
return badgeInstance.icon?.image === componentIconSvg
}
const colorPaletteStore = useColorPaletteStore()
function getCreditsBadge(price: string): LGraphBadge {
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: price,
iconOptions: {
image: componentIconSvg,
size: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
return {
getCreditsBadge,
isCreditsBadge,
updateSubgraphCredits
}
}

View File

@@ -20,8 +20,10 @@ import {
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import { useLinkStore } from '@/stores/linkStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -372,10 +374,14 @@ describe('flushProxyWidgetMigration', () => {
const danglingLinkId = toLinkId(999_999)
expect(host.subgraph.links.has(danglingLinkId)).toBe(false)
primitive.outputs[0].links = [
...(primitive.outputs[0].links ?? []),
danglingLinkId
]
useLinkStore().registerLink(host.subgraph.rootGraph.id, {
id: danglingLinkId,
originNodeId: primitive.id,
originSlot: 0,
targetNodeId: toNodeId(999_999),
targetSlot: 0,
type: '*'
})
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })

View File

@@ -27,8 +27,10 @@ import type {
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { useLinkStore } from '@/stores/linkStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { LinkTopology } from '@/types/linkTopology'
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
@@ -265,21 +267,30 @@ function pickHostValue(
return { value: raw, isHole: false }
}
function primitiveOutputTopologies(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): LinkTopology[] {
return [
...useLinkStore().getOutputSlotLinks(
hostNode.subgraph.rootGraph.id,
primitiveNode.id,
0
)
]
}
function collectTargetsStrict(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] | undefined {
const subgraph = hostNode.subgraph
const output = primitiveNode.outputs?.[0]
const linkIds = output?.links ?? []
const targets: PrimitiveBypassTargetRef[] = []
for (const linkId of linkIds) {
const link = subgraph.links.get(linkId)
if (!link) return undefined
if (link.target_id === UNASSIGNED_NODE_ID) return undefined
for (const topology of primitiveOutputTopologies(hostNode, primitiveNode)) {
if (!subgraph.links.get(topology.id)) return undefined
targets.push({
targetNodeId: link.target_id,
targetSlot: link.target_slot
targetNodeId: topology.targetNodeId,
targetSlot: topology.targetSlot
})
}
return targets
@@ -290,18 +301,12 @@ function collectTargetsSkippingDangling(
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] {
const subgraph = hostNode.subgraph
const linkIds = primitiveNode.outputs?.[0]?.links ?? []
return linkIds.flatMap((linkId) => {
const link = subgraph.links.get(linkId)
return link && link.target_id !== UNASSIGNED_NODE_ID
? [
{
targetNodeId: link.target_id,
targetSlot: link.target_slot
}
]
: []
})
return primitiveOutputTopologies(hostNode, primitiveNode)
.filter((topology) => subgraph.links.get(topology.id))
.map((topology) => ({
targetNodeId: topology.targetNodeId,
targetSlot: topology.targetSlot
}))
}
function cohortDuplicatesPrimitive(

View File

@@ -38,22 +38,6 @@ export function inputForWidget(
return node.getSlotFromWidget(widget)
}
/**
* The interior source of a widget when it is a promoted subgraph input.
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
* host node is a subgraph node and its backing input slot has an interior
* source.
*/
export function widgetPromotedSource(
node: LGraphNode,
widget: IBaseWidget
): PromotedSource | undefined {
if (!node.isSubgraphNode()) return undefined
const input = inputForWidget(node, widget)
if (!input) return undefined
return promotedInputSource(node, input)
}
/**
* Projects a promoted subgraph input into an ordinary widget descriptor. The
* descriptor is store-backed: type/value/options read live from

View File

@@ -11,9 +11,9 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLinkStore } from '@/stores/linkStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toLinkId } from '@/types/linkId'
import type { WidgetId } from '@/types/widgetId'
function promotedInputNames(host: {
@@ -57,7 +57,6 @@ import {
demoteWidget,
getPromotableWidgets,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
@@ -541,53 +540,6 @@ describe('hasUnpromotedWidgets', () => {
})
})
describe('isLinkedPromotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
const node = new LGraphNode('Source')
const input = node.addInput(widgetName, 'STRING')
const widget = node.addWidget('text', widgetName, '', () => {})
input.widget = { name: widget.name }
host.subgraph.add(node)
promoteValueWidgetViaSubgraphInput(host, node, widget)
return node
}
it('returns true for a linked promotion', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
})
it('returns false when no promotion exists', () => {
const host = createTestSubgraphNode(createTestSubgraph())
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
})
it('returns false when sourceWidgetName does not match', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
})
it('identifies linked widgets across different inputs', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const nodeA = promoteSource(host, 'string_a')
const nodeB = promoteSource(host, 'value')
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
})
})
describe('reorderSubgraphInputsByName', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -723,6 +675,13 @@ describe('reorderSubgraphInputsByName', () => {
expect(firstLink?.target_slot).toBe(1)
expect(secondLink?.target_slot).toBe(0)
const store = useLinkStore()
const rootId = subgraph.rootGraph.id
expect(host.isInputConnected(0)).toBe(true)
expect(host.isInputConnected(1)).toBe(true)
expect(store.getInputSlotLink(rootId, host.id, 0)?.id).toBe(secondLink?.id)
expect(store.getInputSlotLink(rootId, host.id, 1)?.id).toBe(firstLink?.id)
})
})
@@ -801,7 +760,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
hostInput.link = toLinkId(9999)
const source = new LGraphNode('External Source')
source.addOutput('out', 'STRING')
host.graph!.add(source)
const externalLink = source.connect(0, host, 0)!
const promotedInputId = hostInput.widgetId
expect(host.subgraph.inputs).toHaveLength(1)
@@ -810,11 +772,9 @@ describe('demoteWidget — axiomatic projection retraction', () => {
demoteWidget(interiorNode, interiorWidget, [host])
expect(host.subgraph.inputs).toHaveLength(1)
expect(host.inputs[0]?.link).toBe(9999)
expect(host.inputs[0]?.link).toBe(externalLink.id)
expect(host.inputs[0]?._widget).toBeUndefined()
expect(
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
).toBe(false)
expect(interiorNode.inputs[0]?.link).toBeNull()
expect(host.widgets).toHaveLength(0)
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
@@ -832,22 +792,21 @@ describe('demoteWidget — axiomatic projection retraction', () => {
})
it('demotes the second of two promoted widgets sharing a source widget name', () => {
const { host, nodeA, widgetA, nodeB, widgetB } =
buildDuplicateNamePromotion()
const { host, nodeA, nodeB, widgetB } = buildDuplicateNamePromotion()
demoteWidget(nodeB, widgetB, [host])
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
expect(isLinkedPromotion(host, String(nodeA.id), widgetA.name)).toBe(true)
expect(nodeB.inputs[0]?.link).toBeNull()
expect(nodeA.inputs[0]?.link).not.toBeNull()
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
const outerHost = createTestSubgraphNode(outerSubgraph)
for (const input of innerHost.inputs) {
expect(
@@ -866,12 +825,8 @@ describe('demoteWidget — axiomatic projection retraction', () => {
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
false
)
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text')).toBe(
true
)
expect(innerHost.inputs.find((i) => i.name === 'text_1')?.link).toBeNull()
expect(innerHost.inputs.find((i) => i.name === 'text')?.link).not.toBeNull()
})
})
@@ -923,8 +878,8 @@ describe('disambiguated nested promotion identity', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
const outerHost = createTestSubgraphNode(outerSubgraph)
for (const input of innerHost.inputs) {
expect(

View File

@@ -30,11 +30,7 @@ type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [LGraphNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
return w.name
}
export function isLinkedPromotion(
function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: SerializedNodeId,
sourceWidgetName: string
@@ -70,11 +66,10 @@ function resolvePromotionSource(
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraphNode.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
if (!targetInput) continue
const { inputNode, input: targetInput } = link.resolve(
subgraphNode.subgraph
)
if (!inputNode || !targetInput) continue
if (inputNode.isSubgraphNode()) {
return {
@@ -145,6 +140,13 @@ function applySubgraphInputOrder(
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
useWidgetValueStore().setNodeWidgetOrder(
subgraphNode.rootGraph.id,
subgraphNode.id,
subgraphNode.inputs.flatMap((input) =>
input.widgetId ? [input.widgetId] : []
)
)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
@@ -217,7 +219,7 @@ function toPromotionSource(
): PromotedWidgetSource {
return {
sourceNodeId: node.id,
sourceWidgetName: getWidgetName(widget)
sourceWidgetName: widget.name
}
}
@@ -238,7 +240,7 @@ export function promoteValueWidgetViaSubgraphInput(
sourceNode: LGraphNode,
sourceWidget: IBaseWidget
): CanonicalPromotionResult {
const sourceWidgetName = getWidgetName(sourceWidget)
const sourceWidgetName = sourceWidget.name
if (isLinkedPromotion(subgraphNode, sourceNode.id, sourceWidgetName)) {
return { ok: true }
}
@@ -262,7 +264,14 @@ export function promoteValueWidgetViaSubgraphInput(
const hostInput = subgraphNode.inputs.find(
(input) => input._subgraphSlot === subgraphInput
)
if (hostInput) hostInput.label = sourceSlot.label
if (hostInput) {
hostInput.label = sourceSlot.label
const promotedState = hostInput.widgetId
? useWidgetValueStore().getWidget(hostInput.widgetId)
: undefined
if (promotedState && sourceSlot.label)
promotedState.label = sourceSlot.label
}
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
@@ -281,22 +290,26 @@ function seedNestedPromotedInputState(
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
const store = useWidgetValueStore()
const sourceState = store.getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
store.registerWidget(
id,
{
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled
},
store.getWidgetRenderState(sourceSlot.widgetId) ?? {}
)
}
function promotePreviewViaExposure(
@@ -366,7 +379,7 @@ export function promoteWidget(
* Removes the host input projecting a linked promotion identified by source.
* Returns true when an input was found and demoted.
*/
export function demotePromotedInput(
function demotePromotedInput(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
@@ -380,7 +393,7 @@ export function demotePromotedInput(
const linkedInput = hostInput?._subgraphSlot
if (!linkedInput) return false
if (hostInput.link != null) {
if (subgraphNode.isInputConnected(subgraphNode.inputs.indexOf(hostInput))) {
linkedInput.disconnect()
} else {
subgraphNode.subgraph.removeInput(linkedInput)
@@ -584,7 +597,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
Sentry.addBreadcrumb({
category: 'subgraph',
level: 'warning',
message: `Failed to promote widget "${getWidgetName(w)}" on node ${n.id}: ${result.reason}`
message: `Failed to promote widget "${w.name}" on node ${n.id}: ${result.reason}`
})
}
}

View File

@@ -109,7 +109,7 @@ describe('resolveConcretePromotedWidget', () => {
subgraph: {
inputNode: { slots: [{ name: 'x', linkIds: [1] }] },
getLink: () => ({
resolve: () => ({ inputNode: recursiveNode })
resolve: () => ({ inputNode: recursiveNode, input: recursiveInput })
}),
getNodeById: () => recursiveNode
}

View File

@@ -25,12 +25,8 @@ export function resolveSubgraphInputLink<TResult>(
const link = node.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(node.subgraph)
if (!inputNode) continue
if (!Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
if (!targetInput) continue
const { inputNode, input: targetInput } = link.resolve(node.subgraph)
if (!inputNode || !targetInput) continue
let cachedTargetWidget:
| ReturnType<LGraphNode['getWidgetFromSlot']>

View File

@@ -1,13 +1,14 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test, vi } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
import type { HasInitialMinSize } from '@/services/litegraphService'
setActivePinia(createTestingPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
type TestAutogrowNode = LGraphNode & {
comfyDynamic: { autogrow: Record<string, unknown> }
@@ -60,7 +61,9 @@ function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
const node2 = testNode()
node2.addOutput('out', '*')
graph.add(node2)
node2.connect(0, node, inputIndex)
const link = node2.connect(0, node, inputIndex)
if (!link) throw new Error(`failed to connect input ${inputIndex}`)
return link
}
function testNode() {
const node: LGraphNode & Partial<HasInitialMinSize> = new LGraphNode('test')
@@ -109,6 +112,66 @@ describe('Dynamic Combos', () => {
expect(node.inputs[1].name).toBe('0.0.0.0')
expect(node.inputs[3].name).toBe('2.2.0.0')
})
test('Shrinking rebuild keeps an unrelated input connected at its new slot', () => {
const graph = new LGraph()
const node = testNode()
addDynamicCombo(node, [[], ['IMAGE', 'IMAGE'], ['IMAGE']])
graph.add(node)
addNodeInput(node, { name: 'other', isOptional: false, type: 'IMAGE' })
node.widgets[0].value = '1'
expect(node.inputs.map((i) => i.name)).toStrictEqual([
'0',
'0.0.0.0',
'0.0.0.1',
'other'
])
const groupLink = connectInput(node, 1, graph)
const removedLink = connectInput(node, 2, graph)
const otherLink = connectInput(node, 3, graph)
node.widgets[0].value = '2'
expect(node.inputs.map((i) => i.name)).toStrictEqual([
'0',
'0.0.0.0',
'other'
])
expect(node.isInputConnected(2)).toBe(true)
expect(node.getInputLink(2)?.id).toBe(otherLink.id)
expect(otherLink.target_slot).toBe(2)
expect(node.getInputLink(1)?.id).toBe(groupLink.id)
expect(groupLink.target_slot).toBe(1)
expect(graph.getLink(removedLink.id)).toBeUndefined()
})
test('Growing rebuild keeps an unrelated input connected at its new slot', () => {
const graph = new LGraph()
const node = testNode()
addDynamicCombo(node, [[], ['IMAGE'], ['IMAGE', 'IMAGE']])
graph.add(node)
addNodeInput(node, { name: 'other', isOptional: false, type: 'IMAGE' })
node.widgets[0].value = '1'
expect(node.inputs.map((i) => i.name)).toStrictEqual([
'0',
'0.0.0.0',
'other'
])
const groupLink = connectInput(node, 1, graph)
const otherLink = connectInput(node, 2, graph)
node.widgets[0].value = '2'
expect(node.inputs.map((i) => i.name)).toStrictEqual([
'0',
'0.0.0.0',
'0.0.0.1',
'other'
])
expect(node.isInputConnected(3)).toBe(true)
expect(node.getInputLink(3)?.id).toBe(otherLink.id)
expect(otherLink.target_slot).toBe(3)
expect(node.getInputLink(1)?.id).toBe(groupLink.id)
expect(groupLink.target_slot).toBe(1)
})
test('Dynamically added widgets have tooltips', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
@@ -285,5 +348,12 @@ describe('Autogrow', () => {
'2.b2',
'aa'
])
for (const slot of [0, 1, 3, 4]) {
expect.soft(newNode.isInputConnected(slot)).toBe(true)
expect.soft(newNode.getInputLink(slot)?.target_slot).toBe(slot)
}
for (const slot of [2, 5, 6]) {
expect.soft(newNode.isInputConnected(slot)).toBe(false)
}
})
})

View File

@@ -2,16 +2,15 @@ import { remove } from 'es-toolkit'
import { shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type {
ISlotType,
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { ISlotType, INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import {
getWidgetIds,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -23,6 +22,8 @@ import {
import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { inputLink } from '@/lib/litegraph/src/node/slotLinks'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
@@ -48,6 +49,16 @@ type AutogrowNode = LGraphNode &
}
}
function syncNodeWidgetOrder(node: LGraphNode) {
const graphId = resolveNodeRootGraphId(node)
if (!graphId || !node.widgets) return
useWidgetValueStore().setNodeWidgetOrder(
graphId,
node.id,
getWidgetIds(node.widgets)
)
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
node.widgets ??= []
const { widget } = input
@@ -99,13 +110,17 @@ function dynamicComboWidget(
if (!node.widgets) throw new Error('Not Reachable')
const newSpec = value ? options[value] : undefined
const inputLinks = captureInputLinks(node)
const removedInputs = remove(node.inputs, isInGroup)
for (const widget of remove(node.widgets, isInGroup)) {
widget.onRemove?.()
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) return
if (!newSpec) {
syncNodeWidgetOrder(node)
return
}
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
@@ -140,6 +155,7 @@ function dynamicComboWidget(
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
syncNodeWidgetOrder(node)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
@@ -149,39 +165,46 @@ function dynamicComboWidget(
throw new Error('Failed to find input socket for ' + widget.name)
return
}
const addedInputs = spliceInputs(node, startingInputLength).map(
(addedInput) => {
const addedInputs = node.inputs
.splice(startingInputLength)
.map((addedInput) => {
const existingInput = node.inputs.findIndex(
(existingInput) => addedInput.name === existingInput.name
)
return existingInput === -1
? addedInput
: spliceInputs(node, existingInput, 1)[0]
}
)
: node.inputs.splice(existingInput, 1)[0]
})
//assume existing inputs are in correct order
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
node.inputs.splice(inputInsertionPoint, 0, ...addedInputs)
const doomedInputs: INodeInputSlot[] = []
const transplants: [number, LLink][] = []
for (const input of removedInputs) {
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
const link = inputLinks.get(input)
if (inputIndex === -1) {
//ride through reconciliation at the tail, then disconnect cleanly
node.inputs.push(input)
node.removeInput(node.inputs.length - 1)
} else {
node.inputs[inputIndex].link = input.link
if (!input.link) continue
const link = node.graph?.links?.[input.link]
if (!link) continue
link.target_slot = inputIndex
node.onConnectionsChange?.(
LiteGraph.INPUT,
inputIndex,
true,
link,
node.inputs[inputIndex]
)
doomedInputs.push(input)
} else if (link) {
inputLinks.set(node.inputs[inputIndex], link)
transplants.push([inputIndex, link])
}
}
reconcileInputLinks(node, inputLinks)
for (const [inputIndex, link] of transplants) {
node.onConnectionsChange?.(
LiteGraph.INPUT,
inputIndex,
true,
link,
node.inputs[inputIndex]
)
}
for (const input of doomedInputs.toReversed()) {
node.removeInput(node.inputs.indexOf(input))
}
node.size[1] = node.computeSize([...node.size])[1]
if (!node.graph) return
@@ -233,33 +256,51 @@ export function applyDynamicInputs(
return true
}
function spliceInputs(
node: LGraphNode,
startIndex: number,
deleteCount = -1,
...toAdd: INodeInputSlot[]
): INodeInputSlot[] {
if (deleteCount < 0) return node.inputs.splice(startIndex)
const ret = node.inputs.splice(startIndex, deleteCount, ...toAdd)
node.inputs.slice(startIndex).forEach((input, index) => {
const link = input.link && node.graph?.links?.get(input.link)
if (link) link.target_slot = startIndex + index
})
return ret
type InputLinks = Map<INodeInputSlot, LLink>
/**
* Snapshot of each input slot's link, keyed by slot object. Must be taken
* while the inputs array is at rest (array position == registered
* target_slot); the slot objects then carry the association through a
* rebuild's array shuffles, as the deleted `input.link` mirror used to.
*/
function captureInputLinks(node: LGraphNode): InputLinks {
const links: InputLinks = new Map()
const { graph } = node
if (!graph) return links
for (const [index, input] of node.inputs.entries()) {
const link = inputLink(graph, node.id, index)
if (link) links.set(input, link)
}
return links
}
/** Re-keys every captured link to its slot's final array position. */
function reconcileInputLinks(node: LGraphNode, links: InputLinks) {
for (const [index, input] of node.inputs.entries()) {
const link = links.get(input)
if (link && link.target_slot !== index) link.target_slot = index
}
}
function changeOutputType(
node: LGraphNode,
output: INodeOutputSlot,
slot: number,
combinedType: ISlotType
) {
const output = node.outputs[slot]
if (output.type === combinedType) return
output.type = combinedType
//check and potentially remove links
if (!node.graph) return
for (const link_id of output.links ?? []) {
const link = node.graph.links[link_id]
const topologies = useLinkStore().getOutputSlotLinks(
node.graph.rootGraph.id,
node.id,
slot
)
for (const topology of topologies) {
const link = node.graph.links[topology.id]
if (!link) continue
const { input, inputNode, subgraphOutput } = link.resolve(node.graph)
const inputType = (input ?? subgraphOutput)?.type
@@ -310,8 +351,7 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
(inp) => inp.name in matchGroup
)
const connectedTypes = groupInputs.map((inp) => {
if (!inp.link) return '*'
const link = this.graph!.links[inp.link]
const link = this.getInputLink(this.inputs.indexOf(inp))
if (!link) return '*'
const { output, subgraphInput } = link.resolve(this.graph!)
return (output ?? subgraphInput)?.type ?? '*'
@@ -335,10 +375,10 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
})
const outputType = commonType(...connectedTypes)
if (!outputType) throw new Error('invalid connection')
this.outputs.forEach((output, idx) => {
this.outputs.forEach((_output, idx) => {
if (!(outputGroups?.[idx] == matchKey)) return
this.outputs[idx] = shallowReactive(this.outputs[idx])
changeOutputType(this, output, outputType)
changeOutputType(this, idx, outputType)
})
app.canvas?.setDirty(true, true)
}
@@ -365,11 +405,12 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
const input = node.inputs[index]
if (!input) return
node.inputs[index] = shallowReactive(input)
const existingLink = node.getInputLink(index)
node.onConnectionsChange?.(
LiteGraph.INPUT,
index,
!!input.link,
input.link ? node.graph?.links?.[input.link] : undefined,
!!existingLink,
existingLink ?? undefined,
input
)
})
@@ -401,6 +442,7 @@ function addAutogrowGroup(
const { max, min, inputSpecs } = node.comfyDynamic.autogrow[groupName]
if (ordinal >= max) return
const inputLinks = captureInputLinks(node)
const namedSpecs = inputSpecs.map((input) => ({
...input,
isOptional: ordinal >= (min ?? 0) || input.isOptional,
@@ -409,7 +451,7 @@ function addAutogrowGroup(
const newInputs = namedSpecs.map((namedSpec) => {
addNodeInput(node, namedSpec)
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
const input = node.inputs.splice(node.inputs.length - 1, 1)[0]
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
ensureWidgetForInput(node, input)
return input
@@ -420,8 +462,8 @@ function addAutogrowGroup(
node.inputs,
(inp) => inp.name === newInput.name
)) {
//NOTE: link.target_slot is updated on spliceInputs call
newInput.link ??= existingInput.link
const link = inputLinks.get(existingInput)
if (link && !inputLinks.has(newInput)) inputLinks.set(newInput, link)
}
}
@@ -435,7 +477,8 @@ function addAutogrowGroup(
inp.name.startsWith(targetName)
)
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
spliceInputs(node, insertionIndex, 0, ...newInputs)
node.inputs.splice(insertionIndex, 0, ...newInputs)
reconcileInputLinks(node, inputLinks)
app.canvas?.setDirty(true, true)
}
@@ -497,6 +540,13 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
return
}
app.canvas?.setDirty(true, true)
// Snapshot each group slot's link before shuffling: donors are always read
// pre-shuffle, matching the sequential copy the mirror used to perform.
const linkByOrdinal = groupInputs.map((inp) =>
node.graph
? inputLink(node.graph, node.id, node.inputs.indexOf(inp))
: undefined
)
//groupBy would be nice here, but may not be supported
for (let column = 0; column < stride; column++) {
for (
@@ -505,9 +555,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
bubbleOrdinal += stride
) {
const curInput = groupInputs[bubbleOrdinal]
curInput.link = groupInputs[bubbleOrdinal + stride].link
if (!curInput.link) continue
const link = node.graph?.links[curInput.link]
const link = linkByOrdinal[bubbleOrdinal + stride]
if (!link) continue
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
if (curIndex === -1) throw new Error('missing input')
@@ -522,7 +570,6 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
}
const lastInput = groupInputs.at(column - stride)
if (!lastInput) continue
lastInput.link = null
node.onConnectionsChange?.(
LiteGraph.INPUT,
node.inputs.length + column - stride,
@@ -534,15 +581,23 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
const removalChecks = groupInputs.slice(min * stride)
let i
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
if (
removalChecks
.slice(i, i + stride)
.some((inp) => node.isInputConnected(node.inputs.indexOf(inp)))
)
break
}
const toRemove = removalChecks.slice(i + stride * 2)
remove(node.inputs, (inp) => toRemove.includes(inp))
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
for (const widget of remove(node.widgets, (w) => w.name === widgetName)) {
widget.onRemove?.()
if (widget.widgetId) useWidgetValueStore().deleteWidget(widget.widgetId)
}
syncNodeWidgetOrder(node)
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

@@ -8,7 +8,7 @@ import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
setActivePinia(createTestingPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
const { addNodeInput } = useLitegraphService()

View File

@@ -2,6 +2,7 @@ import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import type { LGraphNodeConstructor } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { outputLinks } from '@/lib/litegraph/src/node/slotLinks'
import { parseNodeId } from '@/types/nodeId'
import type {
ComfyNode,
@@ -920,9 +921,7 @@ export class GroupNodeHandler {
for (const innerInputId in map) {
const groupSlotId = map[Number(innerInputId)]
if (groupSlotId == null) continue
const slot = node.inputs[groupSlotId]
if (slot.link == null) continue
const link = app.rootGraph.links[slot.link]
const link = node.getInputLink(groupSlotId)
if (!link) continue
const originNode = app.rootGraph.getNodeById(link.origin_id)
originNode?.connect(link.origin_slot, newNode, +innerInputId)
@@ -936,14 +935,10 @@ export class GroupNodeHandler {
groupOutputId < node.outputs?.length;
groupOutputId++
) {
const output = node.outputs[groupOutputId]
if (!output.links) continue
const links = [...output.links]
for (const l of links) {
const links = outputLinks(app.rootGraph, node.id, groupOutputId)
for (const link of links) {
const slot = newToOldOutputMap[groupOutputId]
if (!slot) continue
const link = app.rootGraph.links[l]
if (!link) continue
const targetNode = app.rootGraph.getNodeById(link.target_id)
const selectedId = parseNodeId(selectedIds[slot.node.index ?? 0])
const newNode = selectedId

View File

@@ -635,9 +635,10 @@ useExtensionService().registerExtension({
const extrinsics = result?.[3]
const intrinsics = result?.[4]
modelWidget.value = filePath?.replaceAll('\\', '/')
const modelFilePath = filePath?.replaceAll('\\', '/')
modelWidget.value = modelFilePath
node.properties['Last Time Model File'] = modelWidget.value
node.properties['Last Time Model File'] = modelFilePath
const settings = {
loadFolder: 'output',

View File

@@ -5,6 +5,7 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
import { outputLinks } from '@/lib/litegraph/src/node/slotLinks'
import { app } from '../../scripts/app'
import { getWidgetConfig, mergeIfValid, setWidgetConfig } from './widgetInputs'
@@ -62,19 +63,13 @@ app.registerExtension({
// Prevent multiple connections to different types when we have no input
if (connected && type === LiteGraph.OUTPUT) {
const links = outputLinks(graph, this.id, 0)
// Ignore wildcard nodes as these will be updated to real types
const types = new Set(
this.outputs[0].links
?.map((l) => graph.links[l]?.type)
?.filter((t) => t && t !== '*') ?? []
links.map((l) => l.type).filter((t) => t && t !== '*')
)
if (types.size > 1) {
const linksToDisconnect = []
for (const linkId of this.outputs[0].links ?? []) {
const link = graph.links[linkId]
linksToDisconnect.push(link)
}
linksToDisconnect.pop()
const linksToDisconnect = links.slice(0, -1)
for (const link of linksToDisconnect) {
const node = graph.getNodeById(link.target_id)
node?.disconnectInput(link.target_slot)
@@ -89,9 +84,8 @@ app.registerExtension({
let inputNode = null
while (currentNode) {
updateNodes.unshift(currentNode)
const linkId = currentNode.inputs[0].link
if (linkId !== null) {
const link = graph.links[linkId]
if (currentNode.isInputConnected(0)) {
const link = currentNode.getInputLink(0)
if (!link) return
const node = graph.getNodeById(link.origin_id)
if (!node) return
@@ -122,13 +116,7 @@ app.registerExtension({
let outputType = null
while (nodes.length) {
currentNode = nodes.pop()!
const outputs = currentNode.outputs?.[0]?.links ?? []
for (const linkId of outputs) {
const link = graph.links[linkId]
// When disconnecting sometimes the link is still registered
if (!link) continue
for (const link of outputLinks(graph, currentNode.id, 0)) {
const node = graph.getNodeById(link.target_id)
if (!node) continue
if (node instanceof RerouteNode) {
@@ -176,9 +164,7 @@ app.registerExtension({
: ''
node.setSize(node.computeSize())
for (const l of node.outputs[0].links || []) {
const link = graph.links[l]
if (!link) continue
for (const link of outputLinks(graph, node.id, 0)) {
link.color = color
if (app.configuringGraph) continue
@@ -215,11 +201,9 @@ app.registerExtension({
}
}
if (inputNode?.inputs?.[0]?.link) {
const link = graph.links[inputNode.inputs[0].link]
if (link) {
link.color = color
}
const inputNodeLink = inputNode?.getInputLink(0)
if (inputNodeLink) {
inputNodeLink.color = color
}
}

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -61,6 +63,8 @@ async function createNodeWithFilenamePrefix(
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },

View File

@@ -7,14 +7,13 @@ import type {
LLink
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { outputHasLinks, outputLinks } from '@/lib/litegraph/src/node/slotLinks'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { assetService } from '@/platform/assets/services/assetService'
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
import { app } from '@/scripts/app'
import type { WidgetValue } from '@/types/simplifiedWidget'
import {
ComfyWidgets,
addValueControlWidgets,
@@ -29,7 +28,7 @@ import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
const replacePropertyName = 'Run widget replace on values'
export class PrimitiveNode extends LGraphNode {
controlValues?: TWidgetValue[]
controlValues?: WidgetValue[]
lastType?: string
static override category: string
constructor(title: string) {
@@ -71,7 +70,11 @@ export class PrimitiveNode extends LGraphNode {
}
override onAfterGraphConfigured() {
if (this.outputs[0].links?.length && !this.widgets?.length) {
if (
this.graph &&
outputHasLinks(this.graph, this.id, 0) &&
!this.widgets?.length
) {
this._onFirstConnection()
// Populate widget values from config data
@@ -99,16 +102,16 @@ export class PrimitiveNode extends LGraphNode {
return
}
const links = this.outputs[0].links
const hasLinks = this.graph ? outputHasLinks(this.graph, this.id, 0) : false
if (connected) {
if (links?.length && !this.widgets?.length) {
if (hasLinks && !this.widgets?.length) {
this._onFirstConnection()
}
} else {
// We may have removed a link that caused the constraints to change
this._mergeWidgetConfig()
if (!links?.length) {
if (!hasLinks) {
this.onLastDisconnect()
}
}
@@ -127,7 +130,7 @@ export class PrimitiveNode extends LGraphNode {
return false
}
if (this.outputs[slot].links?.length) {
if (this.graph && outputHasLinks(this.graph, this.id, slot)) {
const valid = this._isValidConnection(input)
if (valid) {
// On connect of additional outputs, copy our value to their widget
@@ -141,13 +144,15 @@ export class PrimitiveNode extends LGraphNode {
private _onFirstConnection(recreating?: boolean) {
// First connection can fire before the graph is ready on initial load so random things can be missing
if (!this.outputs[0].links || !this.graph) {
if (!this.graph) {
this.onLastDisconnect()
return
}
const linkId = this.outputs[0].links[0]
const link = this.graph.links[linkId]
if (!link) return
const [link] = outputLinks(this.graph, this.id, 0)
if (!link) {
if (!outputHasLinks(this.graph, this.id, 0)) this.onLastDisconnect()
return
}
const theirNode = this.graph.getNodeById(link.target_id)
if (!theirNode || !theirNode.inputs) return
@@ -320,14 +325,14 @@ export class PrimitiveNode extends LGraphNode {
private _mergeWidgetConfig() {
// Merge widget configs if the node has multiple outputs
const output = this.outputs[0]
const links = output.links ?? []
const links = this.graph ? outputLinks(this.graph, this.id, 0) : []
const hasConfig = !!output.widget?.[CONFIG]
if (hasConfig) {
delete output.widget?.[CONFIG]
}
if (links?.length < 2 && hasConfig) {
if (links.length < 2 && hasConfig) {
// Copy the widget options from the source
if (links.length) {
this.recreateWidget()
@@ -340,10 +345,7 @@ export class PrimitiveNode extends LGraphNode {
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
if (!isNumber || !this.graph) return
for (const linkId of links) {
const link = this.graph.links[linkId]
if (!link) continue // Can be null when removing a node
for (const link of links) {
const theirNode = this.graph.getNodeById(link.target_id)
if (!theirNode) continue
const theirInput = theirNode.inputs[link.target_slot]
@@ -456,11 +458,11 @@ export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
}
if (!(slot instanceof NodeSlot)) return
const graph = slot.node.graph
if (!graph) return
const link = graph.getLink(slot.link)
const { node } = slot
if (!node.graph) return
const link = node.getInputLink(node.inputs.indexOf(slot))
if (!link) return
const originNode = graph.getNodeById(link.origin_id)
const originNode = node.graph.getNodeById(link.origin_id)
if (!originNode || !isPrimitiveNode(originNode)) return
if (config) {
originNode.recreateWidget()

View File

@@ -1,15 +1,15 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { describe, expect, it, vi } from 'vitest'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot,
LLink
} from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { createMockLLink } from '@/utils/__tests__/litegraphTestUtils'
import type { UUID } from '@/utils/uuid'
vi.mock('@/scripts/app', () => ({
app: {
@@ -21,7 +21,11 @@ vi.mock('@/scripts/app', () => ({
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
type SourceNode = Pick<LGraphNode, 'id' | 'graph' | 'widgets'>
type TargetNode = Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>
const GRAPH_ID: UUID = 'graph-test'
const SOURCE_NODE_ID = toNodeId(1)
function createWidget(
name: string,
@@ -35,11 +39,8 @@ function createWidget(
})
}
function createTargetNode(
widget: IBaseWidget,
id = 7
): Pick<LGraphNode, 'id' | 'inputs' | 'widgets'> {
return fromPartial<Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>>({
function createTargetNode(widget: IBaseWidget, id = 7): TargetNode {
return fromPartial<TargetNode>({
id: toNodeId(id),
inputs: [
fromPartial<INodeInputSlot>({
@@ -50,39 +51,40 @@ function createTargetNode(
})
}
function createLink(targetId: LLink['target_id'], targetSlot = 0): LLink {
return createMockLLink({
target_id: targetId,
target_slot: targetSlot
})
}
function createSourceNode(options: {
link: LLink
targetNode: Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>
targetNode: TargetNode
widgets?: IBaseWidget[]
}): SourceNode {
useLinkStore().registerLink(GRAPH_ID, {
id: toLinkId(1),
originNodeId: SOURCE_NODE_ID,
originSlot: 0,
targetNodeId: options.targetNode.id,
targetSlot: 0,
type: 'INT'
})
return {
id: SOURCE_NODE_ID,
graph: {
links: { 1: options.link },
getNodeById: vi.fn((id: LLink['target_id']) =>
rootGraph: { id: GRAPH_ID },
getNodeById: vi.fn((id: TargetNode['id']) =>
id === options.targetNode.id ? options.targetNode : null
)
} as unknown as NonNullable<LGraphNode['graph']>,
outputs: [{ links: [1] } as INodeOutputSlot],
widgets: options.widgets ?? []
}
}
describe('applyFirstWidgetValueToGraph', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns early when the source widget is missing', () => {
const targetCallback = vi.fn()
const targetWidget = createWidget('value', 'unchanged', targetCallback)
const targetNode = createTargetNode(targetWidget)
const sourceNode = createSourceNode({
link: createLink(targetNode.id),
targetNode
})
const sourceNode = createSourceNode({ targetNode })
expect(() => applyFirstWidgetValueToGraph(sourceNode)).not.toThrow()
expect(targetWidget.value).toBe('unchanged')
@@ -94,7 +96,6 @@ describe('applyFirstWidgetValueToGraph', () => {
const targetWidget = createWidget('value', 'old', targetCallback)
const targetNode = createTargetNode(targetWidget)
const sourceNode = createSourceNode({
link: createLink(targetNode.id),
targetNode,
widgets: [createWidget('source', 'new value')]
})
@@ -116,7 +117,6 @@ describe('applyFirstWidgetValueToGraph', () => {
const targetWidget = createWidget('value', 'old')
const targetNode = createTargetNode(targetWidget)
const sourceNode = createSourceNode({
link: createLink(targetNode.id),
targetNode,
widgets: [createWidget('source', 'draft')]
})

View File

@@ -2,18 +2,30 @@ import type { Point } from '@/lib/litegraph/src/interfaces'
import type { LLink } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { app } from '@/scripts/app'
import { useLinkStore } from '@/stores/linkStore'
import type { NodeId } from '@/types/nodeId'
import type { WidgetValue } from '@/types/simplifiedWidget'
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
type SourceNode = Pick<LGraphNode, 'id' | 'graph' | 'widgets'>
interface TargetEndpoint {
targetNodeId: NodeId
targetSlot: number
}
export function applyFirstWidgetValueToGraph(
node: SourceNode,
extraLinks: LLink[] = [],
transformValue?: (value: TWidgetValue) => TWidgetValue
transformValue?: (value: WidgetValue) => WidgetValue
) {
const output = node.outputs[0]
if (!output?.links?.length || !node.graph) return
const { graph } = node
if (!graph) return
const linked = [
...useLinkStore().getOutputSlotLinks(graph.rootGraph.id, node.id, 0)
]
if (!linked.length) return
const sourceWidget = node.widgets?.[0]
if (!sourceWidget) return
@@ -25,18 +37,22 @@ export function applyFirstWidgetValueToGraph(
const graphMouse: Point = app.canvas?.graph_mouse ?? [0, 0]
const links = [
...output.links.map((linkId) => node.graph!.links[linkId]),
...extraLinks
const endpoints: TargetEndpoint[] = [
...linked.map(({ targetNodeId, targetSlot }) => ({
targetNodeId,
targetSlot
})),
...extraLinks.map((link) => ({
targetNodeId: link.target_id,
targetSlot: link.target_slot
}))
]
for (const linkInfo of links) {
if (!linkInfo) continue
const targetNode = node.graph.getNodeById(linkInfo.target_id)
const input = targetNode?.inputs[linkInfo.target_slot]
for (const endpoint of endpoints) {
const targetNode = graph.getNodeById(endpoint.targetNodeId)
const input = targetNode?.inputs[endpoint.targetSlot]
if (!targetNode || !input) {
console.warn('Unable to resolve node or input for link', linkInfo)
console.warn('Unable to resolve node or input for link', endpoint)
continue
}

View File

@@ -0,0 +1,86 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
import {
createTestSubgraphData,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraph node badge registration', () => {
it('registers a node in the root bucket on add, unregisters on remove', () => {
const graph = new LGraph()
const node = new LGraphNode('n')
graph.add(node)
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual([
node.id
])
graph.remove(node)
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual(
[]
)
})
it('registers only after onNodeAdded has fired', () => {
const graph = new LGraph()
const node = new LGraphNode('n')
let registeredDuringCallback: boolean | undefined
graph.onNodeAdded = () => {
registeredDuringCallback = useNodeBadgeStore()
.registeredNodeIds(graph.rootGraph.id)
.includes(node.id)
}
graph.add(node)
expect(registeredDuringCallback).toBe(false)
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual([
node.id
])
})
it('clears the root bucket when the root graph is cleared', () => {
const graph = new LGraph()
graph.add(new LGraphNode('a'))
graph.add(new LGraphNode('b'))
const graphId = graph.rootGraph.id
graph.clear()
expect(useNodeBadgeStore().registeredNodeIds(graphId)).toEqual([])
})
it('registers subgraph nodes in the root bucket', () => {
const rootGraph = new LGraph()
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
const inner = new LGraphNode('inner')
subgraph.add(inner)
expect(
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
).toContainEqual(inner.id)
})
it('unregisters inner nodes when the subgraph definition is collected', () => {
const rootGraph = new LGraph()
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
const inner = new LGraphNode('inner')
subgraph.add(inner)
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
).toEqual([])
})
})

View File

@@ -0,0 +1,228 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type {
ExportedSubgraph,
ISerialisedNode,
SerialisableGraph
} from '@/lib/litegraph/src/types/serialisation'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
const DEFINITION_ORDER = ['in_a', 'in_b', 'in_c']
/**
* Mimics ComfyNode.configure (src/services/litegraphService.ts): reorders the
* serialized inputs array in place on `data` to match the current node
* definition order before delegating to LGraphNode.configure. This is the
* issue #3348 scenario: a workflow saved before the node definition's input
* order changed (e.g. a forceInput migration in a node pack update).
*/
class ReorderTargetNode extends LGraphNode {
constructor(title?: string) {
super(title ?? 'ReorderTarget')
for (const name of DEFINITION_ORDER) this.addInput(name, 'number')
}
override configure(data: ISerialisedNode): void {
data.inputs = [...(data.inputs ?? [])].sort(
(a, b) =>
DEFINITION_ORDER.indexOf(a.name) - DEFINITION_ORDER.indexOf(b.name)
)
super.configure(data)
}
}
class SourceNode extends LGraphNode {
constructor(title?: string) {
super(title ?? 'Source')
this.addOutput('out', 'number')
}
}
const SUBGRAPH_ID = 'ab111111-1111-4111-8111-111111111111'
/**
* Source and reorder-target nodes connected by three links whose saved
* `target_slot` matches the saved input order [in_b, in_c, in_a], which is
* stale relative to the current definition order [in_a, in_b, in_c].
*/
function shiftedNodesAndLinks(sourceId: number, targetId: number) {
return {
nodes: [
{
id: sourceId,
type: 'test/RealignSource',
pos: [0, 0] as [number, number],
size: [140, 60] as [number, number],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [{ name: 'out', type: 'number', links: [1, 2, 3] }],
properties: {}
},
{
id: targetId,
type: 'test/RealignTarget',
pos: [300, 0] as [number, number],
size: [140, 80] as [number, number],
flags: {},
order: 1,
mode: 0,
inputs: [
{ name: 'in_b', type: 'number', link: 1 },
{ name: 'in_c', type: 'number', link: 2 },
{ name: 'in_a', type: 'number', link: 3 }
],
outputs: [],
properties: {}
}
],
links: [
{
id: 1,
origin_id: sourceId,
origin_slot: 0,
target_id: targetId,
target_slot: 0,
type: 'number'
},
{
id: 2,
origin_id: sourceId,
origin_slot: 0,
target_id: targetId,
target_slot: 1,
type: 'number'
},
{
id: 3,
origin_id: sourceId,
origin_slot: 0,
target_id: targetId,
target_slot: 2,
type: 'number'
}
]
}
}
function emptySubgraphDefinition(): ExportedSubgraph {
return {
id: SUBGRAPH_ID,
version: 1,
revision: 0,
state: { lastNodeId: 0, lastLinkId: 0, lastGroupId: 0, lastRerouteId: 0 },
name: 'Empty Subgraph',
config: {},
inputNode: { id: SUBGRAPH_INPUT_ID, bounding: [0, 0, 120, 60] },
outputNode: { id: SUBGRAPH_OUTPUT_ID, bounding: [300, 0, 120, 60] },
inputs: [],
outputs: [],
widgets: [],
nodes: [],
links: []
}
}
function savedWorkflow(withSubgraphDefinition: boolean): SerialisableGraph {
return {
id: 'ab000000-0000-4000-8000-000000000001',
version: 1,
revision: 0,
state: { lastNodeId: 2, lastLinkId: 3, lastGroupId: 0, lastRerouteId: 0 },
...shiftedNodesAndLinks(1, 2),
...(withSubgraphDefinition
? { definitions: { subgraphs: [emptySubgraphDefinition()] } }
: {})
}
}
function savedWorkflowWithShiftInsideSubgraph(): SerialisableGraph {
return {
id: 'ab000000-0000-4000-8000-000000000002',
version: 1,
revision: 0,
state: { lastNodeId: 0, lastLinkId: 0, lastGroupId: 0, lastRerouteId: 0 },
nodes: [],
links: [],
definitions: {
subgraphs: [
{
...emptySubgraphDefinition(),
name: 'Subgraph With Shifted Inputs',
state: {
lastNodeId: 20,
lastLinkId: 3,
lastGroupId: 0,
lastRerouteId: 0
},
...shiftedNodesAndLinks(10, 20)
}
]
}
}
}
const LINK_BY_INPUT_NAME: Record<string, number> = {
in_a: 3,
in_b: 1,
in_c: 2
}
function assertLinksRealigned(graph: LGraph, targetNodeId: NodeId) {
const target = graph.getNodeById(targetNodeId)!
const linkStore = useLinkStore()
for (const [slot, input] of target.inputs.entries()) {
const expectedLinkId = toLinkId(LINK_BY_INPUT_NAME[input.name])
const link = graph.links.get(expectedLinkId)!
expect(link.target_slot, `link.target_slot for input ${input.name}`).toBe(
slot
)
expect(
linkStore.getInputSlotLink(graph.rootGraph.id, target.id, slot)?.id,
`store registration for input ${input.name} at slot ${slot}`
).toBe(expectedLinkId)
}
}
describe('LGraph.configure input slot realignment (#3348)', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
LiteGraph.registerNodeType('test/RealignSource', SourceNode)
LiteGraph.registerNodeType('test/RealignTarget', ReorderTargetNode)
})
it('re-keys links to reordered input slots', () => {
const graph = new LGraph()
graph.configure(savedWorkflow(false))
assertLinksRealigned(graph, toNodeId(2))
})
it('re-keys root links when subgraph definitions force a data clone', () => {
const graph = new LGraph()
graph.configure(savedWorkflow(true))
assertLinksRealigned(graph, toNodeId(2))
})
it('re-keys links of nodes inside subgraph definitions', () => {
const graph = new LGraph()
graph.configure(savedWorkflowWithShiftInsideSubgraph())
const subgraph = graph.subgraphs.get(SUBGRAPH_ID)!
assertLinksRealigned(subgraph, toNodeId(20))
})
})

View File

@@ -1,10 +1,14 @@
import { describe } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe } from 'vitest'
import { LGraph, LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
import { test } from './__fixtures__/testExtensions'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraph Serialisation', () => {
test('can (de)serialise node / group titles', ({ expect, minimalGraph }) => {
const nodeTitle = 'Test Node'

View File

@@ -12,11 +12,17 @@ import {
Reroute,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type {
SerialisableGraph,
SerialisableLLink,
SerialisableReroute
} from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { useLinkStore } from '@/stores/linkStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
@@ -39,6 +45,8 @@ import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds'
import { test } from './__fixtures__/testExtensions'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function swapNodes(nodes: LGraphNode[]) {
const firstNode = nodes[0]
const lastNode = nodes[nodes.length - 1]
@@ -235,6 +243,25 @@ describe('Floating Links / Reroutes', () => {
expect(graph.reroutes.size).toBe(4)
})
test('slot floating links are derived from link endpoints', ({
expect,
linkedNodesGraph
}) => {
const graph = new LGraph(linkedNodesGraph)
graph.createReroute([0, 0], graph.links.values().next().value!)
const [origin, target] = graph.nodes
origin.disconnectOutput(0)
expect(slotFloatingLinks(graph, 'input', target.id, 0)).toHaveLength(1)
expect(slotFloatingLinks(graph, 'output', origin.id, 0)).toHaveLength(0)
const [floatingLink] = slotFloatingLinks(graph, 'input', target.id, 0)
graph.removeFloatingLink(floatingLink)
expect(slotFloatingLinks(graph, 'input', target.id, 0)).toHaveLength(0)
})
test('Floating reroutes should be removed when neither input nor output is connected', ({
expect,
floatingBranchGraph: graph
@@ -259,6 +286,91 @@ describe('Floating Links / Reroutes', () => {
})
})
describe('Link serialization goldens (ADR-0008 topology-store migration)', () => {
const LINK_KEYS = [
'id',
'origin_id',
'origin_slot',
'target_id',
'target_slot',
'type'
]
function expectContractKeyOrder(link: SerialisableLLink) {
const expectedKeys =
link.parentId === undefined ? LINK_KEYS : [...LINK_KEYS, 'parentId']
expect(Object.keys(link)).toEqual(expectedKeys)
}
test('plain links keep contract key order and round-trip byte-identically', ({
expect,
linkedNodesGraph
}) => {
const first = new LGraph(linkedNodesGraph).asSerialisable()
const second = new LGraph(first).asSerialisable()
expect(first.links?.length).toBeGreaterThan(0)
for (const link of first.links ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
test('reroute-chain links keep contract key order and round-trip byte-identically', ({
expect,
reroutesComplexGraph
}) => {
const first = reroutesComplexGraph.asSerialisable()
const second = new LGraph(first).asSerialisable()
const chainedLinks = (first.links ?? []).filter(
(link) => link.parentId !== undefined
)
expect(chainedLinks.length).toBeGreaterThan(0)
for (const link of first.links ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
test('floating links keep contract key order and round-trip byte-identically', ({
expect,
floatingLinkGraph
}) => {
const first = new LGraph(floatingLinkGraph).asSerialisable()
const second = new LGraph(first).asSerialisable()
expect(first.floatingLinks?.length).toBeGreaterThan(0)
for (const link of first.floatingLinks ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.floatingLinks)).toBe(
JSON.stringify(first.floatingLinks)
)
})
const REROUTE_KEYS = ['id', 'parentId', 'pos', 'linkIds', 'floating'] as const
function expectRerouteContractKeyOrder(reroute: SerialisableReroute) {
const serialized: Record<string, unknown> = JSON.parse(
JSON.stringify(reroute)
)
const expectedKeys = REROUTE_KEYS.filter(
(key) => reroute[key] !== undefined
)
expect(Object.keys(serialized)).toEqual(expectedKeys)
}
test('reroutes keep contract key order and round-trip byte-identically', ({
expect,
reroutesComplexGraph
}) => {
const first = reroutesComplexGraph.asSerialisable()
const second = new LGraph(first).asSerialisable()
const reroutes = first.reroutes ?? []
expect(reroutes.length).toBeGreaterThan(0)
expect(reroutes.some((r) => r.floating !== undefined)).toBe(true)
expect(reroutes.some((r) => r.parentId === undefined)).toBe(true)
for (const reroute of reroutes) expectRerouteContractKeyOrder(reroute)
expect(JSON.stringify(second.reroutes)).toBe(JSON.stringify(first.reroutes))
})
})
describe('Graph Clearing and Callbacks', () => {
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
expect
@@ -307,8 +419,6 @@ describe('Graph Clearing and Callbacks', () => {
})
test('clear() removes graph-scoped preview and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
const graphId = 'graph-clear-cleanup' as UUID
graph.id = graphId
@@ -437,10 +547,6 @@ describe('node:before-removed event', () => {
})
describe('Subgraph Definition Garbage Collection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function createSubgraphWithNodes(rootGraph: LGraph, nodeCount: number) {
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
@@ -830,8 +936,7 @@ describe('_removeDuplicateLinks', () => {
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
graph.state.lastLinkId = linkId
const dup = new LLink(linkId, 'number', source.id, 0, target.id, 0)
graph._links.set(dup.id, dup)
source.outputs[0].links!.push(dup.id)
graph._addLink(dup)
return dup
}
@@ -841,7 +946,8 @@ describe('_removeDuplicateLinks', () => {
for (let i = 0; i < 3; i++) injectDuplicateLink(graph, source, target)
expect(graph._links.size).toBe(4)
expect(source.outputs[0].links).toHaveLength(4)
// The derived output.links view never contained the contested duplicates.
expect(source.outputs[0].links).toHaveLength(1)
graph._removeDuplicateLinks()
@@ -864,6 +970,20 @@ describe('_removeDuplicateLinks', () => {
expect(graph._links.has(dupLink.id)).toBe(false)
})
it('drops purged duplicates from the link store and keeps the survivor indexed', () => {
const { graph, source, target } = createConnectedGraph()
const keptLinkId = target.inputs[0].link!
const dup = injectDuplicateLink(graph, source, target)
graph._removeDuplicateLinks()
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(dup._graphId).toBeUndefined()
expect(store.getInputSlotLink(graphId, target.id, 0)?.id).toBe(keptLinkId)
})
it('keeps the valid link when input.link is at a shifted slot index', () => {
const { graph, source, target } = createConnectedGraph()
const validLinkId = target.inputs[0].link!
@@ -884,19 +1004,21 @@ describe('_removeDuplicateLinks', () => {
expect(graph._links.size).toBe(1)
expect(graph._links.has(validLinkId)).toBe(true)
expect(graph._links.has(dupLink.id)).toBe(false)
expect(target.inputs[1].link).toBe(validLinkId)
const store = useLinkStore()
expect(store.getInputSlotLink(graph.rootGraph.id, target.id, 0)?.id).toBe(
validLinkId
)
})
it('repairs input.link when it points to a removed duplicate', () => {
it('derives input.link from the surviving registration after dedup', () => {
const { graph, source, target } = createConnectedGraph()
const dupLink = injectDuplicateLink(graph, source, target)
// Point input.link to the duplicate (simulating corrupted state)
target.inputs[0].link = dupLink.id
graph._removeDuplicateLinks()
expect(graph._links.size).toBe(1)
expect(graph._links.has(dupLink.id)).toBe(false)
const survivingId = graph._links.keys().next().value!
expect(target.inputs[0].link).toBe(survivingId)
expect(graph._links.has(target.inputs[0].link!)).toBe(true)
@@ -1003,7 +1125,7 @@ describe('Subgraph Unpacking', () => {
return rootGraph.createSubgraph(createTestSubgraphData())
}
function duplicateExistingLink(graph: LGraph, source: LGraphNode) {
function duplicateExistingLink(graph: LGraph) {
const existingLink = graph._links.values().next().value!
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
graph.state.lastLinkId = linkId
@@ -1016,7 +1138,6 @@ describe('Subgraph Unpacking', () => {
existingLink.target_slot
)
graph._links.set(dup.id, dup)
source.outputs[0].links!.push(dup.id)
return dup
}
@@ -1032,7 +1153,7 @@ describe('Subgraph Unpacking', () => {
sourceNode.connect(0, targetNode, 0)
for (let i = 0; i < 3; i++) duplicateExistingLink(subgraph, sourceNode)
for (let i = 0; i < 3; i++) duplicateExistingLink(subgraph)
expect(subgraph._links.size).toBe(4)
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
@@ -1055,7 +1176,7 @@ describe('Subgraph Unpacking', () => {
subgraph.add(targetNode)
sourceNode.connect(0, targetNode, 0)
duplicateExistingLink(subgraph, sourceNode)
duplicateExistingLink(subgraph)
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
@@ -1099,7 +1220,6 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
const SHARED_NODE_IDS = [3, 8, 37]
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
LiteGraph.registerNodeType('dummy', DummyNode)
})

View File

@@ -8,9 +8,14 @@ import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/utils/uuid'
import { createUuidv4, zeroUuid } from '@/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { useLinkStore } from '@/stores/linkStore'
import { useRerouteStore } from '@/stores/rerouteStore'
import { inputHasLink, inputLinkId, outputLinks } from './node/slotLinks'
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, parseNodeId, toNodeId } from '@/types/nodeId'
@@ -20,7 +25,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
import {
groupLinksByTuple,
purgeOrphanedLinks,
repairInputLinks,
realignInputLinkSlots,
selectSurvivorLink
} from './linkDeduplication'
@@ -29,10 +34,20 @@ import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import type { GroupId } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import { LLink } from './LLink'
import {
LLink,
registerLinkTopology,
unregisterAllLinkTopologies,
unregisterLinkTopology
} from './LLink'
import type { LinkId } from './LLink'
import { MapProxyHandler } from './MapProxyHandler'
import { Reroute } from './Reroute'
import {
registerRerouteChain,
Reroute,
unregisterAllRerouteChains,
unregisterRerouteChain
} from './Reroute'
import type { RerouteId } from './Reroute'
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
@@ -59,6 +74,7 @@ import {
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -92,6 +108,7 @@ import type {
import { getAllNestedItems } from './utils/collections'
import {
deduplicateSubgraphNodeIds,
deduplicateSubgraphRerouteIds,
topologicalSortSubgraphs
} from './subgraph/subgraphDeduplication'
@@ -395,6 +412,17 @@ export class LGraph
if (this.isRootGraph && graphId !== zeroUuid) {
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
useLinkStore().clearGraph(graphId)
useRerouteStore().clearGraph(graphId)
useNodeBadgeStore().clearGraph(graphId)
} else {
// Subgraphs and unconfigured (zero-uuid) graphs share their store
// bucket with other graphs, so unregister each link individually.
unregisterAllLinkTopologies(this)
unregisterAllRerouteChains(this)
for (const node of this._nodes) {
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
}
}
this.id = zeroUuid
@@ -674,8 +702,8 @@ export class LGraph
// num of input connections
let num = 0
if (node.inputs) {
for (const input of node.inputs) {
if (input?.link != null) {
for (const [slot] of node.inputs.entries()) {
if (inputHasLink(this, node.id, slot)) {
num += 1
}
}
@@ -705,16 +733,9 @@ export class LGraph
if (!node.outputs) continue
// for every output
for (const output of node.outputs) {
// not connected
// TODO: Confirm functionality, clean condition
if (output?.links == null || output.links.length == 0) continue
for (const [slot] of node.outputs.entries()) {
// for every connection
for (const link_id of output.links) {
const link = this._links.get(link_id)
if (!link) continue
for (const link of outputLinks(this, node.id, slot)) {
// already visited link (ignore it)
if (visited_links[link.id]) continue
@@ -1006,9 +1027,15 @@ export class LGraph
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
const widgetValueStore = useWidgetValueStore()
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
widgetValueStore.setNodeWidgetOrder(
this.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}
this._nodes.push(node)
@@ -1022,6 +1049,10 @@ export class LGraph
this.onNodeAdded?.(node)
// After onNodeAdded: this write wakes a watcher, queueing Vue's flush
// microtask, which must not overtake onNodeAdded's paste-scan microtask.
useNodeBadgeStore().registerNode(this.rootGraph.id, node.id)
this.setDirtyCanvas(true)
this.change()
@@ -1080,8 +1111,8 @@ export class LGraph
// disconnect inputs
if (inputs) {
for (const [i, slot] of inputs.entries()) {
if (slot.link != null) node.disconnectInput(i, true)
for (const [i] of inputs.entries()) {
if (inputHasLink(this, node.id, i)) node.disconnectInput(i, true)
}
}
@@ -1112,6 +1143,11 @@ export class LGraph
if (!hasRemainingReferences) {
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
forEachNode(node.subgraph, (innerNode) =>
useNodeBadgeStore().unregisterNode(this.rootGraph.id, innerNode.id)
)
unregisterAllLinkTopologies(node.subgraph)
unregisterAllRerouteChains(node.subgraph)
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}
@@ -1138,6 +1174,7 @@ export class LGraph
if (pos != -1) this._nodes.splice(pos, 1)
delete this._nodes_by_id[node.id]
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
this.onNodeRemoved?.(node)
@@ -1422,49 +1459,48 @@ export class LGraph
link.id = toLinkId(++this._lastFloatingLinkId)
}
this.floatingLinksInternal.set(link.id, link)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks ??= new Set()
slot._floatingLinks.add(link)
} else {
console.warn(
`Adding invalid floating link: target/slot: [${link.target_id}/${link.target_slot}] origin/slot: [${link.origin_id}/${link.origin_slot}]`
)
}
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.add(link.id)
}
registerLinkTopology(this, link)
return link
}
removeFloatingLink(link: LLink): void {
this.floatingLinksInternal.delete(link.id)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks?.delete(link)
}
unregisterLinkTopology(link)
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.delete(link.id)
if (reroute.floatingLinkIds.size === 0) {
delete reroute.floating
reroute.floating = undefined
}
if (reroute.totalLinks === 0) this.removeReroute(reroute.id)
}
}
/**
* Adds a link to this graph's {@link _links} map and registers its topology
* with the link store. The single entry point for populating {@link _links};
* routing every add through here keeps the store from silently desyncing.
*/
_addLink(link: LLink): void {
this._links.set(link.id, link)
registerLinkTopology(this, link)
}
/**
* Removes a link from this graph's {@link _links} map and unregisters it
* from the link and layout stores. The delete-side counterpart to
* {@link _addLink}; routing every removal through here keeps the stores
* from silently desyncing.
*/
_removeLink(linkId: LinkId): void {
const link = this._links.get(linkId)
if (!link) return
this._links.delete(linkId)
unregisterLinkTopology(link)
layoutStore.deleteLinkLayout(linkId)
}
/**
* Finds the link with the provided ID.
* @param id ID of link to find
@@ -1487,6 +1523,32 @@ export class LGraph
return id == null ? undefined : this.reroutes.get(id)
}
/**
* Adds a reroute to this graph's {@link reroutes} map and registers its
* chain state with the reroute store. The single entry point for
* populating {@link reroutes}; routing every add through here keeps the
* store from silently desyncing.
*/
_addReroute(reroute: Reroute): void {
this.reroutesInternal.set(reroute.id, reroute)
registerRerouteChain(this, reroute)
}
/**
* Removes a reroute from this graph's {@link reroutes} map and
* unregisters it from the reroute and layout stores. The delete-side
* counterpart to {@link _addReroute}.
*/
_removeReroute(id: RerouteId): void {
const reroute = this.reroutesInternal.get(id)
if (!reroute) return
this.reroutesInternal.delete(id)
unregisterRerouteChain(reroute)
const layoutMutations = useLayoutMutations()
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
}
/**
* Configures a reroute on the graph where ID is already known (probably deserialisation).
* Creates the object if it does not exist.
@@ -1496,7 +1558,6 @@ export class LGraph
id,
parentId,
pos,
linkIds,
floating
}: OptionalProps<SerialisableReroute, 'id'>): Reroute {
const rerouteId =
@@ -1508,11 +1569,11 @@ export class LGraph
}
const reroute = this.reroutes.get(rerouteId) ?? new Reroute(rerouteId, this)
const typedParentId =
reroute.parentId =
parentId === undefined ? undefined : toRerouteId(parentId)
const typedLinkIds = linkIds?.map(toLinkId)
reroute.update(typedParentId, pos, typedLinkIds, floating)
this.reroutes.set(rerouteId, reroute)
if (pos) reroute.pos = pos
reroute.floating = floating
this._addReroute(reroute)
return reroute
}
@@ -1530,41 +1591,24 @@ export class LGraph
}
const rerouteId = toRerouteId(Number(this.state.lastRerouteId) + 1)
this.state.lastRerouteId = rerouteId
const linkIds = before instanceof Reroute ? before.linkIds : [before.id]
const floatingLinkIds =
before instanceof Reroute ? before.floatingLinkIds : [before.id]
const reroute = new Reroute(
rerouteId,
this,
pos,
before.parentId,
linkIds,
floatingLinkIds
)
this.reroutes.set(rerouteId, reroute)
const chainLinks =
before instanceof Reroute
? [
...[...before.linkIds].map((id) => this._links.get(id)),
...[...before.floatingLinkIds].map((id) =>
this.floatingLinks.get(id)
)
]
: [before]
const reroute = new Reroute(rerouteId, this, pos, before.parentId)
this._addReroute(reroute)
// Register reroute in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createReroute(
rerouteId,
{ x: pos[0], y: pos[1] },
before.parentId,
Array.from(linkIds)
)
layoutMutations.createReroute(rerouteId, { x: pos[0], y: pos[1] })
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
const reroutes = LLink.getReroutes(this, link)
for (const x of reroutes.filter((x) => x.parentId === before.parentId)) {
x.parentId = rerouteId
}
}
for (const linkId of floatingLinkIds) {
const link = this.floatingLinks.get(linkId)
// Splice the new reroute into every chain that contained `before`
for (const link of chainLinks) {
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
@@ -1582,7 +1626,6 @@ export class LGraph
* @param id ID of reroute to remove
*/
removeReroute(id: RerouteId): void {
const layoutMutations = useLayoutMutations()
const { reroutes } = this
const reroute = reroutes.get(id)
if (!reroute) return
@@ -1625,11 +1668,7 @@ export class LGraph
}
}
reroutes.delete(id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
this._removeReroute(id)
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
// https://github.com/Comfy-Org/litegraph.js/issues/898
@@ -1667,10 +1706,7 @@ export class LGraph
const node = this.getNodeById(sampleLink.target_id)
const keepId = selectSurvivorLink(ids, node)
purgeOrphanedLinks(ids, keepId, this._links, (id) =>
this.getNodeById(toNodeId(id))
)
repairInputLinks(ids, keepId, node)
purgeOrphanedLinks(ids, keepId, this)
}
}
@@ -1908,7 +1944,7 @@ export class LGraph
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
link.origin_id = subgraphNode.id
link.origin_slot = i - 1
this.links.set(link.id, link)
this._addLink(link)
if (subgraphOutput instanceof SubgraphOutput) {
subgraphOutput.connect(
subgraphNode.findOutputSlotByType(link.type, true, true),
@@ -2042,30 +2078,6 @@ export class LGraph
group.pos[1] += offsetY
toSelect.push(group)
}
//cleanup reoute.linkIds now, but leave link.parentIds dangling
for (const islot of subgraphNode.inputs) {
if (!islot.link) continue
const link = this.links.get(islot.link)
if (!link) {
console.warn('Broken link', islot, islot.link)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
for (const oslot of subgraphNode.outputs) {
for (const linkId of oslot.links ?? []) {
const link = this.links.get(linkId)
if (!link) {
console.warn('Broken link', oslot, linkId)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
}
const newLinks: {
oid: NodeId
oslot: number
@@ -2079,7 +2091,7 @@ export class LGraph
for (const [, link] of subgraphNode.subgraph._links) {
let externalParentId: RerouteId | undefined
if (link.origin_id === SUBGRAPH_INPUT_ID) {
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
const outerLinkId = inputLinkId(this, subgraphNode.id, link.origin_slot)
if (!outerLinkId) {
console.error('Missing Link ID when unpacking')
continue
@@ -2100,9 +2112,11 @@ export class LGraph
link.origin_id = origin_id
}
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
[]) {
const sublink = this.links[linkId]
for (const sublink of outputLinks(
this,
subgraphNode.id,
link.target_slot
)) {
newLinks.push({
oid: link.origin_id,
oslot: link.origin_slot,
@@ -2200,89 +2214,96 @@ export class LGraph
}
newLink.id = created.id
}
// Migrate the subgraph's reroutes to fresh ids at their new positions.
const rerouteIdMap = new Map<RerouteId, RerouteId>()
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
if (
reroute.parentId !== undefined &&
rerouteIdMap.get(reroute.parentId) === undefined
) {
console.error('Missing Parent ID')
}
const migratedRerouteId = toRerouteId(
Number(this.state.lastRerouteId) + 1
)
this.state.lastRerouteId = migratedRerouteId
const migratedReroute = new Reroute(migratedRerouteId, this, [
const oldReroutes = subgraphNode.subgraph.reroutes
for (const reroute of oldReroutes.values()) {
const migratedId = toRerouteId(Number(this.state.lastRerouteId) + 1)
this.state.lastRerouteId = migratedId
const migratedReroute = new Reroute(migratedId, this, [
reroute.pos[0] + offsetX,
reroute.pos[1] + offsetY
])
rerouteIdMap.set(reroute.id, migratedReroute.id)
this.reroutes.set(migratedReroute.id, migratedReroute)
rerouteIdMap.set(reroute.id, migratedId)
this._addReroute(migratedReroute)
toSelect.push(migratedReroute)
}
//iterate over newly created links to update reroute parentIds
/**
* A reroute chain segment, terminal-first. `complete` is false when the
* walk stopped at a broken reference or a cycle; stitching stops there
* too.
*/
interface ChainSegment {
segment: RerouteId[]
complete: boolean
}
/** Walks a chain of this graph's own reroutes upstream from `start`. */
const externalSegment = (start: RerouteId | undefined): ChainSegment => {
const segment: RerouteId[] = []
const visited = new Set<RerouteId>()
let id = start
while (id !== undefined) {
if (visited.has(id)) {
console.error('Infinite parentId loop when unpacking')
return { segment, complete: false }
}
visited.add(id)
const reroute = this.reroutes.get(id)
if (!reroute) {
console.error('Broken Id link when unpacking')
return { segment, complete: false }
}
segment.push(id)
id = reroute.parentId
}
return { segment, complete: true }
}
/** Walks an old subgraph chain from `start`, emitting migrated ids. */
const internalSegment = (start: RerouteId | undefined): ChainSegment => {
const segment: RerouteId[] = []
const visited = new Set<RerouteId>()
let id = start
while (id !== undefined) {
if (visited.has(id)) {
console.error('Infinite parentId loop when unpacking')
return { segment, complete: false }
}
visited.add(id)
const migratedId = rerouteIdMap.get(id)
if (migratedId === undefined) {
console.error('Broken Id link when unpacking')
return { segment, complete: false }
}
segment.push(migratedId)
id = oldReroutes.get(id)?.parentId
}
return { segment, complete: true }
}
// Stitch each link's chain from its internal (migrated) and external
// segments, ordered by which side was nearest the input.
for (const newLink of dedupedNewLinks) {
const linkInstance = this.links.get(newLink.id)
if (!linkInstance) {
continue
}
let instance: Reroute | LLink | undefined = linkInstance
let parentId: RerouteId | undefined
if (newLink.externalFirst) {
parentId = newLink.eparent
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
if (!instance) continue
parentId = newLink.iparent
while (parentId) {
const migratedId = rerouteIdMap.get(parentId)
if (!migratedId) {
console.error('Broken Id link when unpacking')
break
}
instance.parentId = migratedId
instance = this.reroutes.get(migratedId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
if (!oldReroute) {
console.error('Broken Id link when unpacking')
break
}
parentId = oldReroute.parentId
}
if (!instance) break
if (!newLink.externalFirst) {
parentId = newLink.eparent
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
if (!linkInstance) continue
const internal = internalSegment(newLink.iparent)
const external = externalSegment(newLink.eparent)
const [first, second] = newLink.externalFirst
? [external, internal]
: [internal, external]
const chain = first.complete
? [...first.segment, ...second.segment]
: first.segment
let segmentEnd: LLink | Reroute = linkInstance
for (const rerouteId of chain) {
segmentEnd.parentId = rerouteId
const next = this.reroutes.get(rerouteId)
if (!next) break
segmentEnd = next
}
}
@@ -2471,7 +2492,6 @@ export class LGraph
data: ISerialisedGraph | SerialisableGraph,
keep_old?: boolean
): boolean | undefined {
const layoutMutations = useLayoutMutations()
const options: LGraphEventMap['configuring'] = {
data,
clearGraph: !keep_old
@@ -2495,7 +2515,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.createFromArray(linkData)
this._links.set(link.id, link)
this._addLink(link)
}
}
// #region `extra` embeds for v0.4
@@ -2536,7 +2556,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.create(linkData)
this._links.set(link.id, link)
this._addLink(link)
}
}
@@ -2590,6 +2610,20 @@ export class LGraph
)
: undefined
if (deduplicated) {
const reservedRerouteIds = new Set<number>()
for (const reroute of this.reroutes.values())
reservedRerouteIds.add(Number(reroute.id))
for (const sg of this.subgraphs.values())
for (const reroute of sg.reroutes.values())
reservedRerouteIds.add(Number(reroute.id))
deduplicateSubgraphRerouteIds(
deduplicated.subgraphs,
reservedRerouteIds,
this.state
)
}
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
@@ -2659,14 +2693,10 @@ export class LGraph
}
}
// Drop broken reroutes
// Drop reroutes that no live link or floating link passes through
for (const reroute of this.reroutes.values()) {
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
// Clean up layout store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
if (reroute.totalLinks === 0) {
this._removeReroute(reroute.id)
}
}
@@ -2676,6 +2706,12 @@ export class LGraph
// without proper cleanup of the previous connection.
this._removeDuplicateLinks()
// Node configure() overrides may have reordered serialized inputs in
// place to match current node definitions; re-key links to the slots
// that reference them. Uses nodeDataMap: the effective (possibly
// deduplicated-clone) data nodes were actually configured from.
realignInputLinkSlots(this, nodeDataMap.values())
// groups
this._groups.length = 0
const groupData = data.groups

View File

@@ -3,11 +3,6 @@ import { LGraphIcon } from './LGraphIcon'
import type { LGraphIconOptions } from './LGraphIcon'
import { cachedMeasureText } from './utils/textMeasureCache'
export enum BadgePosition {
TopLeft = 'top-left',
TopRight = 'top-right'
}
export interface LGraphBadgeOptions {
text: string
fgColor?: string

View File

@@ -34,6 +34,8 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function createSerialisedNode(
id: number,
type: string,
@@ -256,6 +258,31 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
registeredTypesToCleanup.push(type)
}
it('prunes pasted reroutes that no pasted link passes through', () => {
const nodeType = 'test/clipboard-reroute-prune'
registerClipboardNodeType(nodeType)
const rootGraph = new LGraph()
const canvas = createCanvas(rootGraph)
const source = LiteGraph.createNode(nodeType)!
rootGraph.add(source)
const target = LiteGraph.createNode(nodeType)!
rootGraph.add(target)
const link = source.connect(0, target, 0)!
rootGraph.createReroute([50, 50], link)
// Copying only the reroute leaves it with no pasted link through it
const result = canvas._deserializeItems(
canvas._serializeItems([...rootGraph.reroutes.values()]),
{ position: [300, 300] }
)
expect(result?.reroutes.size).toBe(0)
expect(result?.created).toHaveLength(0)
expect(rootGraph.reroutes.size).toBe(1)
})
it('reconnects pasted inputs when clipboard node IDs differ from link endpoint types', () => {
const nodeType = 'test/clipboard-node-id-normalization'
registerClipboardNodeType(nodeType)

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId } from '@/types/nodeId'
@@ -13,6 +15,8 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
class TestNode extends LGraphNode {

View File

@@ -79,21 +79,18 @@ function createTestLink(
targetNode.id,
inputSlot
)
graph._links.set(linkId, link)
sourceNode.outputs[outputSlot].links ??= []
sourceNode.outputs[outputSlot].links!.push(linkId)
targetNode.inputs[inputSlot].link = linkId
graph._addLink(link)
return link
}
describe('drawConnections widget-input slot positioning', () => {
describe('drawConnections', () => {
let graph: LGraph
let canvas: LGraphCanvas
let canvasElement: HTMLCanvasElement
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
canvasElement = document.createElement('canvas')
canvasElement.width = 800
@@ -177,6 +174,31 @@ describe('drawConnections widget-input slot positioning', () => {
expect(arrangeSpy).not.toHaveBeenCalled()
})
it('never reads the deprecated slot link mirrors in a connect-draw-serialize cycle', () => {
const deprecationCallback = vi.fn()
const originalCallbacks = LiteGraph.onDeprecationWarning
LiteGraph.onDeprecationWarning = [deprecationCallback]
LiteGraph.alwaysRepeatWarnings = true
try {
const sourceNode = new LGraphNode('Source')
sourceNode.addOutput('out', 'STRING')
graph.add(sourceNode)
const targetNode = new LGraphNode('Target')
targetNode.addInput('in', 'STRING')
graph.add(targetNode)
sourceNode.connect(0, targetNode, 0)
canvas.drawConnections(createMockCtx())
graph.asSerialisable()
expect(deprecationCallback).not.toHaveBeenCalled()
} finally {
LiteGraph.onDeprecationWarning = originalCallbacks
LiteGraph.alwaysRepeatWarnings = false
}
})
it('positions widget-input slots when display name differs from slot.widget.name', () => {
const sourceNode = new LGraphNode('Source')
sourceNode.pos = [0, 100]

View File

@@ -1,4 +1,6 @@
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -15,6 +17,8 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}
}))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function createGhostTestHarness() {
const canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

@@ -1,4 +1,6 @@
import { fromAny } from '@total-typescript/shoehorn'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
@@ -20,6 +22,8 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}
}))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -19,6 +21,8 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
}
}))
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraphCanvas slot hit detection', () => {
let graph: LGraph
let canvas: LGraphCanvas

View File

@@ -26,7 +26,13 @@ import { LGraphNode } from './LGraphNode'
import type { NodeProperty } from './LGraphNode'
import { parseNodeId, serializeNodeId } from '@/types/nodeId'
import type { SerializedNodeId } from '@/types/nodeId'
import { LLink } from './LLink'
import { LLink, slotFloatingLinks } from './LLink'
import {
inputHasLink,
inputLinkId,
outputLinkIds,
outputLinks
} from './node/slotLinks'
import type { LinkId } from './LLink'
import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
@@ -62,7 +68,6 @@ import type {
INodeSlot,
INodeSlotContextItem,
ISlotType,
LinkNetwork,
LinkSegment,
NewNodePosition,
NullableProperties,
@@ -2780,28 +2785,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
} else if (!node.flags.collapsed) {
const { inputs, outputs } = node
function hasRelevantOutputLinks(
output: INodeOutputSlot,
network: LinkNetwork
): boolean {
const outputLinks = [
...(output.links ?? []),
...[...(output._floatingLinks ?? new Set())]
]
return outputLinks.some(
(linkId) =>
typeof linkId === 'number' && network.getLink(linkId) !== undefined
)
}
// Outputs
if (outputs) {
for (const [i, output] of outputs.entries()) {
const link_pos = node.getOutputPos(i)
if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) {
// Drag multiple output links
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
linkConnector.moveOutputLink(graph, output)
if (e.shiftKey && outputLinks(graph, node.id, i).length > 0) {
linkConnector.moveOutputLink(graph, node, output)
this._linkConnectorDrop()
return
}
@@ -2847,12 +2838,15 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctrlOrMeta &&
e.altKey &&
!e.shiftKey
if (input.link !== null || input._floatingLinks?.size) {
if (
inputHasLink(graph, node.id, i) ||
slotFloatingLinks(graph, 'input', node.id, i).length > 0
) {
// Existing link
if (shouldBreakLink || LiteGraph.click_do_break_link_to) {
node.disconnectInput(i, true)
} else if (e.shiftKey || this.allow_reconnect_links) {
linkConnector.moveInputLink(graph, input)
linkConnector.moveInputLink(graph, node, input)
}
}
@@ -4294,14 +4288,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
// Remap linkIds
for (const reroute of reroutes.values()) {
const ids = [...reroute.linkIds].map((x) => links.get(x)?.id ?? x)
reroute.update(reroute.parentId, undefined, ids, reroute.floating)
// Remove any invalid items
if (!reroute.validateLinks(graph.links, graph.floatingLinks)) {
// Remove reroutes that no pasted link passes through
for (const [sourceId, reroute] of reroutes) {
if (reroute.totalLinks === 0) {
graph.removeReroute(reroute.id)
reroutes.delete(sourceId)
const index = created.indexOf(reroute)
if (index !== -1) created.splice(index, 1)
}
}
@@ -4608,16 +4602,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
this.onNodeSelected?.(item)
// Highlight links
if (item.inputs) {
for (const input of item.inputs) {
if (input.link == null) continue
this.highlighted_links[input.link] = true
const { graph: highlightGraph } = this
if (item.inputs && highlightGraph) {
for (const [i] of item.inputs.entries()) {
const linkId = inputLinkId(highlightGraph, item.id, i)
if (linkId == null) continue
this.highlighted_links[linkId] = true
}
}
if (item.outputs) {
for (const id of item.outputs.flatMap((x) => x.links)) {
if (id == null) continue
this.highlighted_links[id] = true
if (item.outputs && highlightGraph) {
for (const [i] of item.outputs.entries()) {
for (const id of outputLinkIds(highlightGraph, item.id, i)) {
this.highlighted_links[id] = true
}
}
}
}
@@ -4664,19 +4661,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Clear link highlight
if (item.inputs) {
for (const input of item.inputs) {
if (input.link == null) continue
for (const [i] of item.inputs.entries()) {
const linkId = inputLinkId(graph, item.id, i)
if (linkId == null) continue
const node = LLink.getOriginNode(graph, input.link)
const node = LLink.getOriginNode(graph, linkId)
if (node && this.selectedItems.has(node)) continue
delete this.highlighted_links[input.link]
delete this.highlighted_links[linkId]
}
}
if (item.outputs) {
for (const id of item.outputs.flatMap((x) => x.links)) {
if (id == null) continue
for (const id of item.outputs.flatMap((_x, i) =>
outputLinkIds(graph, item.id, i)
)) {
const node = LLink.getTargetNode(graph, id)
if (node && this.selectedItems.has(node)) continue
@@ -4802,14 +4800,18 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (oldNode) this.selected_nodes[oldNode.id] = oldNode
// Highlight links
if (keepSelected.inputs) {
for (const input of keepSelected.inputs) {
if (input.link == null) continue
this.highlighted_links[input.link] = true
const { graph: rehighlightGraph } = this
if (keepSelected.inputs && rehighlightGraph) {
for (const [i] of keepSelected.inputs.entries()) {
const linkId = inputLinkId(rehighlightGraph, keepSelected.id, i)
if (linkId == null) continue
this.highlighted_links[linkId] = true
}
}
if (keepSelected.outputs) {
for (const id of keepSelected.outputs.flatMap((x) => x.links)) {
if (keepSelected.outputs && rehighlightGraph) {
for (const id of keepSelected.outputs.flatMap((_x, i) =>
outputLinkIds(rehighlightGraph, keepSelected.id, i)
)) {
if (id == null) continue
this.highlighted_links[id] = true
}
@@ -6042,48 +6044,41 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
node.arrange()
}
for (const node of nodes) {
// for every input (we render just inputs because it is easier as every slot can only have one input)
const { inputs } = node
if (!inputs?.length) continue
// Render every link at its target input (each input holds at most one link).
for (const link of graph._links.values()) {
const node = graph.getNodeById(link.target_id)
const input = node?.inputs[link.target_slot]
if (!node || !input) continue
for (const [i, input] of inputs.entries()) {
if (!input || input.link == null) continue
const endPos: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(node, link.target_slot, true)
: node.getInputPos(link.target_slot)
const link_id = input.link
const link = graph._links.get(link_id)
if (!link) continue
// find link info
const start_node = graph.getNodeById(link.origin_id)
if (start_node == null) continue
const endPos: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(node, i, true)
: node.getInputPos(i)
const outputId = link.origin_slot
const startPos: Point =
outputId === -1
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(start_node, outputId, false)
: start_node.getOutputPos(outputId)
// find link info
const start_node = graph.getNodeById(link.origin_id)
if (start_node == null) continue
const output = start_node.outputs[outputId]
if (!output) continue
const outputId = link.origin_slot
const startPos: Point =
outputId === -1
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
? getSlotPosition(start_node, outputId, false)
: start_node.getOutputPos(outputId)
const output = start_node.outputs[outputId]
if (!output) continue
this._renderAllLinkSegments(
ctx,
link,
startPos,
endPos,
visibleReroutes,
now,
output.dir,
input.dir
)
}
this._renderAllLinkSegments(
ctx,
link,
startPos,
endPos,
visibleReroutes,
now,
output.dir,
input.dir
)
}
if (subgraph) {
@@ -8691,7 +8686,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (node.getSlotMenuOptions) {
menu_info = node.getSlotMenuOptions(slot)
} else {
if (slot.output?.links?.length || slot.input?.link != null) {
const slotConnected = slot.output
? node.isOutputConnected(slot.slot)
: node.isInputConnected(slot.slot)
if (slotConnected) {
menu_info.push({ content: 'Disconnect Links', slot })
}
@@ -8701,7 +8699,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
'Both in put and output slots were null when processing context menu.'
)
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
if (!_slot.nameLocked && !slot.input?.widget) {
menu_info.push({ content: 'Rename Slot', slot })
}

View File

@@ -17,6 +17,7 @@ import {
} from '@/lib/litegraph/src/litegraph'
import { test } from './__fixtures__/testExtensions'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { createMockLGraphNodeWithArrayBoundingRect } from '@/utils/__tests__/litegraphTestUtils'
import { toNodeId } from '@/types/nodeId'
@@ -95,7 +96,7 @@ describe('LGraphNode', () => {
outputs: node.outputs?.map((o) => ({
name: o.name,
type: o.type,
links: o.links,
links: o.links ? [...o.links] : o.links,
slot_index: o.slot_index
}))
}
@@ -134,7 +135,7 @@ describe('LGraphNode', () => {
expect(node.outputs.length).toEqual(1)
expect(node.outputs[0].name).toEqual('TestOutput')
expect(node.outputs[0].type).toEqual('number')
expect(node.outputs[0].links).toEqual([])
expect(node.outputs[0].links).toBeNull()
expect(node.outputs[0]).instanceOf(NodeOutputSlot)
// Should not override existing outputs
@@ -184,7 +185,7 @@ describe('LGraphNode', () => {
const disconnected = node2.disconnectInput(0)
expect(disconnected).toBe(true)
expect(node2.inputs[0].link).toBeNull()
expect(node1.outputs[0].links?.length).toBe(0)
expect(node1.outputs[0].links).toBeNull()
expect(graph._links.has(link!.id)).toBe(false)
// Test disconnecting by slot name
@@ -260,7 +261,7 @@ describe('LGraphNode', () => {
)
expect(disconnectedByName).toBe(true)
expect(targetNode1.inputs[0].link).toBeNull()
expect(sourceNode.outputs[1].links?.length).toBe(0)
expect(sourceNode.outputs[1].links).toBeNull()
// Test disconnecting all connections from an output
const link4 = sourceNode.connect(0, targetNode1, 0)
@@ -282,6 +283,58 @@ describe('LGraphNode', () => {
const alreadyDisconnected = sourceNode.disconnectOutput(0)
expect(alreadyDisconnected).toBe(false)
})
function createConnectedPair() {
const sourceNode = new LGraphNode('SourceNode')
const targetNode = new LGraphNode('TargetNode')
sourceNode.configure(
getMockISerialisedNode({
id: 1,
outputs: [{ name: 'Output1', type: 'number', links: [] }]
})
)
targetNode.configure(
getMockISerialisedNode({
id: 2,
inputs: [{ name: 'Input1', type: 'number', link: null }]
})
)
const graph = new LGraph()
graph.add(sourceNode)
graph.add(targetNode)
expect(sourceNode.connect(0, targetNode, 0)).not.toBeNull()
return { graph, sourceNode, targetNode }
}
function observeInputDisconnects(targetNode: LGraphNode) {
const observed: { connected: boolean; link: unknown }[] = []
targetNode.onConnectionsChange = function (type, slot, isConnected) {
if (type !== NodeSlotType.INPUT || isConnected) return
observed.push({
connected: this.isInputConnected(slot),
link: this.getInputLink(slot)
})
}
return observed
}
test('target sees disconnected state inside onConnectionsChange when all output links are removed', () => {
const { sourceNode, targetNode } = createConnectedPair()
const observed = observeInputDisconnects(targetNode)
expect(sourceNode.disconnectOutput(0)).toBe(true)
expect(observed).toEqual([{ connected: false, link: null }])
})
test('target sees disconnected state inside onConnectionsChange when the source node is removed', () => {
const { graph, sourceNode, targetNode } = createConnectedPair()
const observed = observeInputDisconnects(targetNode)
graph.remove(sourceNode)
expect(observed).toEqual([{ connected: false, link: null }])
})
})
describe('Applies correct link type on connection', () => {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { computed } from 'vue'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { registerLinkTopology } from './LLink'
import {
createTestSubgraph,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
describe('LLink ↔ linkStore integration', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('connect registers, disconnect removes', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
expect(store.isInputSlotConnected(graph.rootGraph.id, b.id, 0)).toBe(true)
graph.removeLink(link.id)
expect(store.isInputSlotConnected(graph.rootGraph.id, b.id, 0)).toBe(false)
})
it('link.parentId writes are observable through the store query', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
const parentId = computed(
() => store.getInputSlotLink(graph.rootGraph.id, b.id, 0)?.parentId
)
expect(parentId.value).toBeUndefined()
link.parentId = toRerouteId(7)
expect(parentId.value).toBe(7)
})
it('keeps writing to a disconnected link after it leaves the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
graph.removeLink(link.id)
expect(() => {
link.target_slot = 3
}).not.toThrow()
expect(link.target_slot).toBe(3)
})
it('keeps the winner registered when a colliding loser link disconnects', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const winner = a.connect(0, b, 0)!
const loser = new LLink(winner.id, 'INT', a.id, 0, b.id, 0)
registerLinkTopology(graph, loser)
loser.disconnect(graph)
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(winner.id)
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(true)
})
it('unregisters a subgraph definitions links when its last instance is removed', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
const rootGraph = subgraph.rootGraph
const subgraphNode = createTestSubgraphNode(subgraph)
rootGraph.add(subgraphNode)
const store = useLinkStore()
expect(store.getInputSlotLink(rootGraph.id, second.id, 0)?.id).toBe(
innerLink.id
)
rootGraph.remove(subgraphNode)
expect(store.isInputSlotConnected(rootGraph.id, second.id, 0)).toBe(false)
})
it('keeps a subgraph definitions links registered while other instances remain', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
const rootGraph = subgraph.rootGraph
const keptInstance = createTestSubgraphNode(subgraph)
const removedInstance = createTestSubgraphNode(subgraph, { id: 99 })
rootGraph.add(keptInstance)
rootGraph.add(removedInstance)
rootGraph.remove(removedInstance)
const store = useLinkStore()
expect(store.getInputSlotLink(rootGraph.id, second.id, 0)?.id).toBe(
innerLink.id
)
})
it('clearing a subgraph unregisters its links but keeps root links', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const rootGraph = subgraph.rootGraph
const [first, second] = subgraph.nodes
first.connect(0, second, 0)
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', '*')
b.addInput('in', '*')
rootGraph.add(a)
rootGraph.add(b)
const rootLink = a.connect(0, b, 0)!
subgraph.clear()
const store = useLinkStore()
expect(store.isInputSlotConnected(rootGraph.id, second.id, 0)).toBe(false)
expect(store.getInputSlotLink(rootGraph.id, b.id, 0)?.id).toBe(rootLink.id)
})
it('clear() unregisters an unconfigured graphs links from the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const graphId = graph.rootGraph.id
const store = useLinkStore()
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(link.id)
graph.clear()
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(false)
})
it('detaches a floating link from the store when it is removed', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addOutput('out', '*')
graph.add(a)
const floating = new LLink(
toLinkId(7),
'*',
a.id,
0,
UNASSIGNED_NODE_ID,
-1
)
graph.addFloatingLink(floating)
const graphId = graph.rootGraph.id
expect(floating._graphId).toBe(graphId)
graph.removeFloatingLink(floating)
expect(floating._graphId).toBeUndefined()
floating.origin_slot = 5
expect(floating.origin_slot).toBe(5)
})
it('keeps both links registered when two target slots are swapped', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const first = a.connect(0, b, 0)!
const second = a.connect(0, b, 1)!
first.target_slot = 1
second.target_slot = 0
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(b.isInputConnected(0)).toBe(true)
expect(b.isInputConnected(1)).toBe(true)
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(second.id)
expect(store.getInputSlotLink(graphId, b.id, 1)?.id).toBe(first.id)
})
it('moving a link via target_slot reindexes the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
const nodeId = b.id
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 0)).toBe(true)
link.target_slot = 1
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 0)).toBe(
false
)
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 1)).toBe(true)
})
})

View File

@@ -2,6 +2,7 @@ import { describe, expect } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { test } from './__fixtures__/testExtensions'
@@ -21,4 +22,17 @@ describe('LLink', () => {
expect(link.hasOrigin(4, 2)).toBe(true)
expect(link.hasTarget(5, 3)).toBe(true)
})
test('exposes topology fields backed by a single _state object', () => {
const link = new LLink(toLinkId(1), 'INT', 5, 0, 9, 2)
expect(link.origin_id).toBe(toNodeId(5))
link.target_slot = 4
expect(link._state.targetSlot).toBe(4)
expect(link.asSerialisable()).toMatchObject({
id: toLinkId(1),
origin_id: 5,
target_slot: 4,
type: 'INT'
})
})
})

View File

@@ -4,14 +4,18 @@ import {
} from '@/lib/litegraph/src/constants'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import type { EndpointPatch } from '@/stores/linkStore'
import type { LinkId } from '@/types/linkId'
import type { LinkTopology } from '@/types/linkTopology'
import type { RerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import type { Reroute } from './Reroute'
@@ -27,8 +31,6 @@ import type {
} from './interfaces'
import type { Serialisable, SerialisableLLink } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type { LinkId } from '@/types/linkId'
export type SerialisedLLinkArray = [
id: number,
@@ -93,22 +95,88 @@ type BasicReadonlyNetwork = Pick<
'getNodeById' | 'links' | 'getLink' | 'inputNode' | 'outputNode'
>
/** Routes an endpoint patch through {@link useLinkStore} if the link is registered, otherwise writes {@link LLink._state} directly. */
function applyEndpointPatch(link: LLink, patch: EndpointPatch): void {
if (link._graphId) {
useLinkStore().updateEndpoint(link._graphId, link._state, patch)
} else {
Object.assign(link._state, patch)
}
}
// this is the class in charge of storing link information
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
static _drawDebug = false
/**
* The link's topology state. Once registered with {@link useLinkStore},
* this is the store's reactive proxy, so field writes are tracked.
*/
_state: LinkTopology
/** The graph this link is registered with in {@link useLinkStore}, if any. */
_graphId?: UUID
/** Link ID */
id: LinkId
parentId?: RerouteId
type: ISlotType
get id() {
return this._state.id
}
set id(value: LinkId) {
this._state.id = value
}
get type() {
return this._state.type
}
set type(value: ISlotType) {
this._state.type = value
}
/** Output node ID */
origin_id: NodeId
get origin_id() {
return this._state.originNodeId
}
set origin_id(value: NodeId) {
applyEndpointPatch(this, { originNodeId: value })
}
/** Output slot index */
origin_slot: number
get origin_slot() {
return this._state.originSlot
}
set origin_slot(value: number) {
applyEndpointPatch(this, { originSlot: value })
}
/** Input node ID */
target_id: NodeId
get target_id() {
return this._state.targetNodeId
}
set target_id(value: NodeId) {
applyEndpointPatch(this, { targetNodeId: value })
}
/** Input slot index */
target_slot: number
get target_slot() {
return this._state.targetSlot
}
set target_slot(value: number) {
applyEndpointPatch(this, { targetSlot: value })
}
get parentId() {
return this._state.parentId
}
set parentId(value: RerouteId | undefined) {
this._state.parentId = value
}
data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown
@@ -165,13 +233,15 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
target_slot: number,
parentId?: RerouteId
) {
this.id = id
this.type = type
this.origin_id = toNodeId(origin_id)
this.origin_slot = origin_slot
this.target_id = toNodeId(target_id)
this.target_slot = target_slot
this.parentId = parentId
this._state = {
id,
type,
originNodeId: toNodeId(origin_id),
originSlot: origin_slot,
targetNodeId: toNodeId(target_id),
targetSlot: target_slot,
parentId
}
this._data = null
// center
@@ -462,19 +532,15 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
network.addFloatingLink(newLink)
}
network.links.delete(this.id)
unregisterLinkTopology(this)
layoutStore.deleteLinkLayout(this.id)
for (const reroute of reroutes) {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.totalLinks) {
network.reroutes.delete(reroute.id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
network._removeReroute(reroute.id)
}
}
network.links.delete(this.id)
// Delete link from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteLink(this.id)
}
/**
@@ -505,3 +571,79 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
return copy
}
}
/**
* Finds the floating links attached to a slot. A floating link has exactly
* one assigned endpoint, so its attachment is fully encoded in its own
* origin/target fields; nothing is stored on the slot.
* @param network The network whose floating links to search
* @param side Which side of the slot's node the links attach to
* @param nodeId The node (or subgraph IO node id) owning the slot
* @param slot The slot index
*/
export function slotFloatingLinks(
network: Pick<ReadonlyLinkNetwork, 'floatingLinks'>,
side: 'input' | 'output',
nodeId: NodeId,
slot: number
): LLink[] {
const result: LLink[] = []
for (const link of network.floatingLinks.values()) {
const attached =
side === 'input'
? link.target_id === nodeId && link.target_slot === slot
: link.origin_id === nodeId && link.origin_slot === slot
if (attached) result.push(link)
}
return result
}
/**
* Registers a link's topology into {@link useLinkStore} and adopts the
* store's reactive proxy as {@link LLink._state}, so the store and the link
* always agree and field writes are tracked. Call this at every site that
* adds a link to a graph's link map (or floating link map).
*
* {@link LLink._graphId} is only set when the store keeps this link's state:
* a link that loses a first-wins id collision stays detached, so its writes
* and removal cannot corrupt the winner's registration.
* @param graph The graph (or subgraph) the link belongs to
* @param link The link to register
*/
export function registerLinkTopology(
graph: Pick<LGraph, 'rootGraph'>,
link: LLink
): void {
if (link.id === toLinkId(-1)) return // transient toFloating clone
const graphId = graph.rootGraph.id
const registered = useLinkStore().registerLink(graphId, link._state)
if (registered) {
link._state = registered
link._graphId = graphId
}
}
/**
* Removes a link's topology from {@link useLinkStore} and detaches the link.
* No-op for links that never won registration ({@link LLink._graphId} unset),
* so a first-wins collision loser cannot remove the winner's entry.
* @param link The link to unregister
*/
export function unregisterLinkTopology(link: LLink): void {
if (!link._graphId) return
useLinkStore().deleteLink(link._graphId, link._state)
link._graphId = undefined
}
/**
* Unregisters every link and floating link a graph owns. Used when a graph's
* links leave the store without a whole-bucket wipe: subgraph-definition
* removal, and clearing a graph that shares its bucket with other graphs.
* @param graph The graph whose links should be unregistered
*/
export function unregisterAllLinkTopologies(
graph: Pick<LGraph, 'links' | 'floatingLinks'>
): void {
for (const link of graph.links.values()) unregisterLinkTopology(link)
for (const link of graph.floatingLinks.values()) unregisterLinkTopology(link)
}

View File

@@ -0,0 +1,187 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { computed } from 'vue'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useRerouteStore } from '@/stores/rerouteStore'
import { toRerouteId } from '@/types/rerouteId'
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
function connectedGraph() {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
return { graph, a, b, link }
}
describe('Reroute ↔ rerouteStore integration', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('createReroute registers the chain, removeReroute unregisters it', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(
reroute.id
)
graph.removeReroute(reroute.id)
expect(store.getReroute(graph.rootGraph.id, reroute.id)).toBeUndefined()
})
it('setReroute (deserialisation) registers the chain', () => {
const { graph } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.setReroute({
id: toRerouteId(3),
parentId: undefined,
pos: [5, 5],
linkIds: []
})
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(3)
})
it('class parentId writes are observable through the store query', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const first = graph.createReroute([10, 10], link)!
const second = graph.createReroute([20, 20], first)!
const parentId = computed(
() => store.getReroute(graph.rootGraph.id, first.id)?.parentId
)
expect(parentId.value).toBe(second.id)
first.parentId = undefined
expect(parentId.value).toBeUndefined()
})
it('disconnect pruning an empty reroute unregisters it', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [10, 10],
linkIds: [link.id]
})
link.parentId = reroute.id
link.disconnect(graph)
expect(graph.reroutes.size).toBe(0)
expect(store.getReroute(graph.rootGraph.id, reroute.id)).toBeUndefined()
})
it('clear() removes the graphs chains from the store', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
const graphId = graph.rootGraph.id
graph.clear()
expect(store.getReroute(graphId, reroute.id)).toBeUndefined()
})
it('deduplicates colliding subgraph reroute ids into one root bucket', () => {
LiteGraph.registerNodeType('dummy', LGraphNode)
const data = structuredClone(
duplicateSubgraphNodeIds
) as unknown as SerialisableGraph
const [a, b] = data.definitions!.subgraphs!
a.reroutes = [{ id: 1, pos: [0, 0], linkIds: [1] }]
a.links![0].parentId = toRerouteId(1)
b.reroutes = [{ id: 1, pos: [0, 0], linkIds: [2] }]
b.links![0].parentId = toRerouteId(1)
const graph = new LGraph(data)
const store = useRerouteStore()
const subgraphs = [...graph.subgraphs.values()]
const rerouteIds = subgraphs.map((sg) => [...sg.reroutes.keys()][0])
expect(new Set(rerouteIds).size).toBe(2)
for (const sg of subgraphs) {
const [reroute] = [...sg.reroutes.values()]
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(
reroute.id
)
const [link] = [...sg._links.values()]
expect(link.parentId).toBe(reroute.id)
}
})
it('linkIds follows the chain without manual set maintenance', () => {
const { graph, link } = connectedGraph()
const reroute = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [10, 10],
linkIds: []
})
link.parentId = reroute.id
expect([...reroute.linkIds]).toEqual([link.id])
link.parentId = undefined
expect(reroute.linkIds.size).toBe(0)
})
it('snapToGrid mirrors the snapped position into the layout store', () => {
const { graph, link } = connectedGraph()
const reroute = graph.createReroute([12, 17], link)!
reroute.snapToGrid(10)
expect(layoutStore.getRerouteLayout(reroute.id)?.position).toEqual({
x: reroute.pos[0],
y: reroute.pos[1]
})
})
it('refuses parentId writes that would create a cycle, allows repair', () => {
const { graph, link } = connectedGraph()
const first = graph.createReroute([10, 10], link)!
const second = graph.createReroute([20, 20], first)!
expect(first.parentId).toBe(second.id)
second.parentId = first.id
expect(second.parentId).toBeUndefined()
second._chain.parentId = first.id
second.parentId = undefined
expect(second.parentId).toBeUndefined()
})
it('floating marker survives through the store state', () => {
const { graph, a, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
a.disconnectOutput(0)
expect(reroute.floating).toEqual({ slotType: 'input' })
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.floating).toEqual({
slotType: 'input'
})
})
})

View File

@@ -1,10 +1,15 @@
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { EMPTY_MEMBERSHIP, useRerouteStore } from '@/stores/rerouteStore'
import type { RerouteMembership } from '@/stores/rerouteStore'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { FloatingRerouteSlot, RerouteChain } from '@/types/rerouteChain'
import type { RerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
import { LayoutSource } from '@/renderer/core/layout/types'
import { LGraphBadge } from './LGraphBadge'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
@@ -25,14 +30,9 @@ import type { Serialisable, SerialisableReroute } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type { FloatingRerouteSlot } from '@/types/rerouteChain'
export type { RerouteId } from '@/types/rerouteId'
/** The input or output slot that an incomplete reroute link is connected to. */
export interface FloatingRerouteSlot {
/** Floating connection to an input or output */
slotType: 'input' | 'output'
}
/**
* Represents an additional point on the graph that a link path will travel through. Used for visual organisation only.
*
@@ -59,24 +59,53 @@ export class Reroute
/** The network this reroute belongs to. Contains all valid links and reroutes. */
private readonly network: WeakRef<LinkNetwork>
private parentIdInternal?: RerouteId
/**
* The reroute's chain state. Once registered with {@link useRerouteStore},
* this is the store's reactive proxy, so field writes are tracked.
*/
_chain: RerouteChain
/** The graph this reroute is registered with in {@link useRerouteStore}, if any. */
_graphId?: UUID
public get parentId(): RerouteId | undefined {
return this.parentIdInternal
return this._chain.parentId
}
/** Ignores attempts to create an infinite loop. @inheritdoc */
public set parentId(value) {
if (value === this.id) return
if (this.getReroutes() === null) return
this.parentIdInternal = value
if (value !== undefined && this.wouldCreateParentCycle(value)) return
this._chain.parentId = value
}
/** Walks the proposed parent chain, checking it never returns to this reroute. */
private wouldCreateParentCycle(parentId: RerouteId): boolean {
const reroutes = this.network.deref()?.reroutes
const visited = new Set<RerouteId>([this.id])
let nextId: RerouteId | undefined = parentId
while (nextId !== undefined) {
if (visited.has(nextId)) return true
visited.add(nextId)
nextId = reroutes?.get(nextId)?.parentId
}
return false
}
public get parent(): Reroute | undefined {
return this.network.deref()?.getReroute(this.parentIdInternal)
return this.network.deref()?.getReroute(this._chain.parentId)
}
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
floating?: FloatingRerouteSlot
get floating(): FloatingRerouteSlot | undefined {
return this._chain.floating
}
set floating(value: FloatingRerouteSlot | undefined) {
this._chain.floating = value
}
private readonly posInternal: Point = [0, 0]
/** @inheritdoc */
@@ -120,11 +149,24 @@ export class Reroute
/** @inheritdoc */
selected?: boolean
/** The ID ({@link LLink.id}) of every link using this reroute */
linkIds: Set<LinkId>
private get membership(): RerouteMembership {
return this._graphId
? useRerouteStore().getMembership(this._graphId, this.id)
: EMPTY_MEMBERSHIP
}
/**
* The ID ({@link LLink.id}) of every link using this reroute.
* Derived from the links' parentId chains; never stored.
*/
get linkIds(): ReadonlySet<LinkId> {
return this.membership.linkIds
}
/** The ID ({@link LLink.id}) of every floating link using this reroute */
floatingLinkIds: Set<LinkId>
get floatingLinkIds(): ReadonlySet<LinkId> {
return this.membership.floatingLinkIds
}
/** Cached cos */
cos: number = 0
@@ -201,61 +243,18 @@ export class Reroute
* @param id Unique identifier for this reroute
* @param network The network of links this reroute belongs to. Internally converted to a WeakRef.
* @param pos Position in graph coordinates
* @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute
*/
constructor(
id: RerouteId,
network: LinkNetwork,
pos?: Point,
parentId?: RerouteId,
linkIds?: Iterable<LinkId>,
floatingLinkIds?: Iterable<LinkId>
parentId?: RerouteId
) {
this.id = id
this.network = new WeakRef(network)
this._chain = { id }
this.parentId = parentId
if (pos) this.pos = pos
this.linkIds = new Set(linkIds)
this.floatingLinkIds = new Set(floatingLinkIds)
}
/**
* Applies a new parentId to the reroute, and optinoally a new position and linkId.
* Primarily used for deserialisation.
* @param parentId The ID of the reroute prior to this reroute, or
* `undefined` if it is the first reroute connected to a nodes output
* @param pos The position of this reroute
* @param linkIds All link IDs that pass through this reroute
*/
update(
parentId: RerouteId | undefined,
pos?: Point,
linkIds?: Iterable<LinkId>,
floating?: FloatingRerouteSlot
): void {
this.parentId = parentId
if (pos) this.pos = pos
if (linkIds) this.linkIds = new Set(linkIds)
this.floating = floating
}
/**
* Validates the linkIds this reroute has. Removes broken links.
* @param links Collection of valid links
* @returns true if any links remain after validation
*/
validateLinks(
links: ReadonlyMap<LinkId, LLink>,
floatingLinks: ReadonlyMap<LinkId, LLink>
): boolean {
const { linkIds, floatingLinkIds } = this
for (const linkId of linkIds) {
if (!links.has(linkId)) linkIds.delete(linkId)
}
for (const linkId of floatingLinkIds) {
if (!floatingLinks.has(linkId)) floatingLinkIds.delete(linkId)
}
return linkIds.size > 0 || floatingLinkIds.size > 0
}
/**
@@ -268,15 +267,15 @@ export class Reroute
*/
getReroutes(visited = new Set<Reroute>()): Reroute[] | null {
// No parentId - last in the chain
if (this.parentIdInternal === undefined) return [this]
if (this._chain.parentId === undefined) return [this]
// Invalid chain - looped
if (visited.has(this)) return null
visited.add(this)
const parent = this.network.deref()?.reroutes.get(this.parentIdInternal)
const parent = this.network.deref()?.reroutes.get(this._chain.parentId)
// Invalid parent (or network) - drop silently to recover
if (!parent) {
this.parentIdInternal = undefined
this._chain.parentId = undefined
return [this]
}
@@ -295,14 +294,14 @@ export class Reroute
withParentId: RerouteId,
visited = new Set<Reroute>()
): Reroute | null | undefined {
if (this.parentIdInternal === withParentId) return this
if (this._chain.parentId === withParentId) return this
if (visited.has(this)) return null
visited.add(this)
if (this.parentIdInternal === undefined) return
if (this._chain.parentId === undefined) return
return this.network
.deref()
?.reroutes.get(this.parentIdInternal)
?.reroutes.get(this._chain.parentId)
?.findNextReroute(withParentId, visited)
}
@@ -386,31 +385,14 @@ export class Reroute
/**
* Changes the origin node/output of all floating links that pass through this reroute.
* @param node The new origin node
* @param output The new origin output slot
* @param index The slot index of {@link output}
* @param index The slot index of the new origin output
*/
setFloatingLinkOrigin(
node: LGraphNode,
output: INodeOutputSlot,
index: number
) {
const network = this.network.deref()
setFloatingLinkOrigin(node: LGraphNode, index: number) {
const floatingOutLinks = this.getFloatingLinks('output')
if (!floatingOutLinks)
throw new Error('[setFloatingLinkOrigin]: Invalid network.')
if (!floatingOutLinks.length) return
output._floatingLinks ??= new Set()
for (const link of floatingOutLinks) {
// Update cached floating links
output._floatingLinks.add(link)
network
?.getNodeById(link.origin_id)
?.outputs[link.origin_slot]?._floatingLinks?.delete(link)
// Update the floating link
link.origin_id = node.id
link.origin_slot = index
}
@@ -437,47 +419,22 @@ export class Reroute
const offsetY = LiteGraph.NODE_SLOT_HEIGHT * 0.7
const { pos } = this
const previousPos = { x: pos[0], y: pos[1] }
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round((pos[1] - offsetY) / snapTo) + offsetY
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.moveReroute(this.id, { x: pos[0], y: pos[1] }, previousPos)
return true
}
removeAllFloatingLinks() {
for (const linkId of this.floatingLinkIds) {
this.removeFloatingLink(linkId)
}
}
removeFloatingLink(linkId: LinkId) {
const network = this.network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(linkId)
if (!floatingLink) {
console.warn(
`[Reroute.removeFloatingLink] Floating link not found: ${linkId}, ignoring and discarding ID.`
)
this.floatingLinkIds.delete(linkId)
return
}
network.removeFloatingLink(floatingLink)
}
/**
* Removes a link or floating link from this reroute, by matching link object instance equality.
* @param link The link to remove.
* @remarks Does not remove the link from the network.
*/
removeLink(link: LLink) {
const network = this.network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(link.id)
if (link === floatingLink) {
this.floatingLinkIds.delete(link.id)
} else {
this.linkIds.delete(link.id)
for (const linkId of [...this.floatingLinkIds]) {
const floatingLink = network.floatingLinks.get(linkId)
if (floatingLink) network.removeFloatingLink(floatingLink)
}
}
@@ -688,7 +645,7 @@ export class Reroute
id,
parentId,
pos: [pos[0], pos[1]],
linkIds: [...linkIds],
linkIds: [...linkIds].sort((a, b) => a - b),
floating: this.floating ? { slotType: this.floating.slotType } : undefined
}
}
@@ -822,3 +779,68 @@ function getNextPos(
function getDirection(fromPos: Point, toPos: Point) {
return Math.atan2(toPos[1] - fromPos[1], toPos[0] - fromPos[0])
}
/**
* Marks a link's reroute chain as no longer floating: clears each reroute's
* floating marker and drag state, and removes any floating link that
* terminates at the chain's last reroute. Call when a real link connects
* through the chain.
* @param network The network containing the chain
* @param link The link whose chain was just connected
*/
export function anchorRerouteChain(network: LinkNetwork, link: LLink): void {
const reroutes = LLink.getReroutes(network, link)
for (const reroute of reroutes) {
reroute.floating = undefined
reroute._dragging = undefined
}
const lastReroute = reroutes.at(-1)
if (!lastReroute) return
for (const linkId of lastReroute.floatingLinkIds) {
const floatingLink = network.floatingLinks.get(linkId)
if (floatingLink?.parentId === lastReroute.id) {
network.removeFloatingLink(floatingLink)
}
}
}
/**
* Registers a reroute's chain state into {@link useRerouteStore} and adopts
* the store's reactive proxy as {@link Reroute._chain}, so the store and the
* reroute always agree and field writes are tracked. Call this at every
* site that adds a reroute to a graph's reroute map.
* @param graph The graph (or subgraph) the reroute belongs to
* @param reroute The reroute to register
*/
export function registerRerouteChain(
graph: Pick<LGraph, 'rootGraph'>,
reroute: Reroute
): void {
const graphId = graph.rootGraph.id
reroute._chain = useRerouteStore().registerReroute(graphId, reroute._chain)
reroute._graphId = graphId
}
/**
* Removes a reroute's chain state from {@link useRerouteStore} and detaches
* the reroute. No-op for reroutes that were never registered.
* @param reroute The reroute to unregister
*/
export function unregisterRerouteChain(reroute: Reroute): void {
if (!reroute._graphId) return
useRerouteStore().deleteReroute(reroute._graphId, reroute._chain)
reroute._graphId = undefined
}
/**
* Unregisters every reroute a graph owns. Used when a graph's reroutes
* leave the store without a whole-bucket wipe: subgraph-definition removal,
* and clearing a graph that shares its bucket with other graphs.
* @param graph The graph whose reroutes should be unregistered
*/
export function unregisterAllRerouteChains(
graph: Pick<LGraph, 'reroutes'>
): void {
for (const reroute of graph.reroutes.values()) unregisterRerouteChain(reroute)
}

View File

@@ -0,0 +1,15 @@
import type { LGraphIconOptions } from './LGraphIcon'
const icons = new Map<string, LGraphIconOptions>()
/** Maps a {@link BadgeData.iconKey} to canvas icon options. */
export function registerBadgeIcon(
key: string,
options: LGraphIconOptions
): void {
icons.set(key, options)
}
export function resolveBadgeIcon(key: string): LGraphIconOptions | undefined {
return icons.get(key)
}

View File

@@ -0,0 +1,93 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import { LGraph, LGraphNode, Reroute } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { toNodeId, UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { createTestSubgraph } from '../subgraph/__fixtures__/subgraphHelpers'
import { FloatingRenderLink } from './FloatingRenderLink'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function inputFloatingLink(targetId: number, targetSlot: number): LLink {
return new LLink(
toLinkId(-1),
'INT',
UNASSIGNED_NODE_ID,
-1,
toNodeId(targetId),
targetSlot
)
}
describe('FloatingRenderLink', () => {
it('connectToSubgraphOutput re-targets the floating link to the output slot', () => {
const subgraph = createTestSubgraph({
nodeCount: 1,
outputs: [{ name: 'result', type: 'INT' }]
})
const [node] = subgraph.nodes
const floatingLink = inputFloatingLink(Number(node.id), 0)
floatingLink.parentId = toRerouteId(1)
const reroute = new Reroute(toRerouteId(1), subgraph, [0, 0])
subgraph._addReroute(reroute)
subgraph.addFloatingLink(floatingLink)
const renderLink = new FloatingRenderLink(
subgraph,
floatingLink,
'input',
reroute
)
renderLink.connectToSubgraphOutput(subgraph.outputs[0])
expect(floatingLink.target_id).toBe(SUBGRAPH_OUTPUT_ID)
expect(floatingLink.target_slot).toBe(0)
expect(floatingLink.origin_id).toBe(UNASSIGNED_NODE_ID)
expect(slotFloatingLinks(subgraph, 'input', node.id, 0)).toHaveLength(0)
})
})
describe('slot removal renumbers floating link attachments', () => {
it('removeInput shifts floating links on later inputs', () => {
const graph = new LGraph()
const node = new LGraphNode('N')
node.addInput('a', 'INT')
node.addInput('b', 'INT')
graph.add(node)
const floatingLink = inputFloatingLink(Number(node.id), 1)
graph.addFloatingLink(floatingLink)
node.removeInput(0)
expect(slotFloatingLinks(graph, 'input', node.id, 0)).toHaveLength(1)
expect(floatingLink.target_slot).toBe(0)
})
it('removeOutput shifts floating links on later outputs', () => {
const graph = new LGraph()
const node = new LGraphNode('N')
node.addOutput('a', 'INT')
node.addOutput('b', 'INT')
graph.add(node)
const floatingLink = new LLink(
toLinkId(-1),
'INT',
node.id,
1,
UNASSIGNED_NODE_ID,
-1
)
graph.addFloatingLink(floatingLink)
node.removeOutput(0)
expect(slotFloatingLinks(graph, 'output', node.id, 0)).toHaveLength(1)
expect(floatingLink.origin_slot).toBe(0)
})
})

View File

@@ -147,15 +147,13 @@ export class FloatingRenderLink implements RenderLink {
input: INodeInputSlot,
_events?: CustomEventTarget<LinkConnectorEventMap>
): void {
// Disconnect before re-targeting, or the floating link would be
// caught (and removed) by the target slot's floating-link cleanup.
node.disconnectInput(node.inputs.indexOf(input))
const floatingLink = this.link
floatingLink.target_id = node.id
floatingLink.target_slot = node.inputs.indexOf(input)
node.disconnectInput(node.inputs.indexOf(input))
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
}
connectToOutput(
@@ -166,10 +164,6 @@ export class FloatingRenderLink implements RenderLink {
const floatingLink = this.link
floatingLink.origin_id = node.id
floatingLink.origin_slot = node.outputs.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
}
connectToSubgraphInput(
@@ -179,10 +173,6 @@ export class FloatingRenderLink implements RenderLink {
const floatingLink = this.link
floatingLink.origin_id = SUBGRAPH_INPUT_ID
floatingLink.origin_slot = input.parent.slots.indexOf(input)
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
}
connectToSubgraphOutput(
@@ -190,12 +180,8 @@ export class FloatingRenderLink implements RenderLink {
_events?: CustomEventTarget<LinkConnectorEventMap>
): void {
const floatingLink = this.link
floatingLink.origin_id = SUBGRAPH_OUTPUT_ID
floatingLink.origin_slot = output.parent.slots.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
floatingLink.target_id = SUBGRAPH_OUTPUT_ID
floatingLink.target_slot = output.parent.slots.indexOf(output)
}
connectToRerouteInput(
@@ -208,10 +194,6 @@ export class FloatingRenderLink implements RenderLink {
floatingLink.target_id = inputNode.id
floatingLink.target_slot = inputNode.inputs.indexOf(input)
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
events.dispatch('input-moved', this)
}
@@ -226,10 +208,6 @@ export class FloatingRenderLink implements RenderLink {
floatingLink.origin_id = outputNode.id
floatingLink.origin_slot = outputNode.outputs.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
events.dispatch('output-moved', this)
}
}

View File

@@ -1,5 +1,7 @@
// oxlint-disable no-empty-pattern
import { test as baseTest, describe, expect, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { test as baseTest, beforeEach, describe, expect, vi } from 'vitest'
import type {
MovingInputLink,
@@ -26,8 +28,11 @@ import {
createMockNodeOutputSlot
} from '@/utils/__tests__/litegraphTestUtils'
import { registerLinkTopology } from '../LLink'
import { registerRerouteChain } from '../Reroute'
interface TestContext {
network: LinkNetwork & { add(node: LGraphNode): void }
network: LinkNetwork & { add(node: LGraphNode): void; rootGraph: LGraph }
connector: LinkConnector
setConnectingLinks: (value: ConnectingLink[]) => void
createTestNode: (id: number, slotType?: ISlotType) => LGraphNode
@@ -39,6 +44,8 @@ interface TestContext {
) => LLink
}
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
const test = baseTest.extend<TestContext>({
network: async ({}, use) => {
const graph = new LGraph()
@@ -46,7 +53,7 @@ const test = baseTest.extend<TestContext>({
const reroutes = new Map<RerouteId, Reroute>()
await use({
links: new Map<LinkId, LLink>(),
links: graph._links,
reroutes,
floatingLinks,
getLink: graph.getLink.bind(graph),
@@ -59,7 +66,9 @@ const test = baseTest.extend<TestContext>({
getReroute: ((id: RerouteId | null | undefined) =>
id == null ? undefined : reroutes.get(id)) as LinkNetwork['getReroute'],
removeReroute: (id: RerouteId) => reroutes.delete(id),
add: (node: LGraphNode) => graph.add(node)
_removeReroute: (id: RerouteId) => void reroutes.delete(id),
add: (node: LGraphNode) => graph.add(node),
rootGraph: graph
})
},
@@ -125,11 +134,10 @@ describe('LinkConnector', () => {
sourceNode.addOutput('out', slotType)
targetNode.addInput('in', slotType)
const link = new LLink(toLinkId(1), slotType, 1, 0, 2, 0)
network.links.set(link.id, link)
targetNode.inputs[0].link = link.id
const link = sourceNode.connect(0, targetNode, 0)!
expect(link).toBeTruthy()
connector.moveInputLink(network, targetNode.inputs[0])
connector.moveInputLink(network, targetNode, targetNode.inputs[0])
expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(true)
@@ -146,6 +154,7 @@ describe('LinkConnector', () => {
expect(() => {
connector.moveInputLink(
network,
new LGraphNode('mock'),
createMockNodeInputSlot({ link: toLinkId(1) })
)
}).toThrow('Already dragging links.')
@@ -165,11 +174,10 @@ describe('LinkConnector', () => {
sourceNode.addOutput('out', slotType)
targetNode.addInput('in', slotType)
const link = new LLink(toLinkId(1), slotType, 1, 0, 2, 0)
network.links.set(link.id, link)
sourceNode.outputs[0].links = [link.id]
const link = sourceNode.connect(0, targetNode, 0)!
expect(link).toBeTruthy()
connector.moveOutputLink(network, sourceNode.outputs[0])
connector.moveOutputLink(network, sourceNode, sourceNode.outputs[0])
expect(connector.state.connectingTo).toBe('output')
expect(connector.state.draggingExistingLinks).toBe(true)
@@ -187,6 +195,7 @@ describe('LinkConnector', () => {
expect(() => {
connector.moveOutputLink(
network,
new LGraphNode('mock'),
createMockNodeOutputSlot({ links: [toLinkId(1)] })
)
}).toThrow('Already dragging links.')
@@ -241,11 +250,11 @@ describe('LinkConnector', () => {
targetNode.addInput('in', 'number')
const link = createTestLink(1, 1, 2)
const reroute = new Reroute(toRerouteId(1), network, [0, 0], undefined, [
link.id
])
const reroute = new Reroute(toRerouteId(1), network, [0, 0])
network.reroutes.set(reroute.id, reroute)
registerRerouteChain(network, reroute)
link.parentId = reroute.id
registerLinkTopology(network, link)
connector.dragFromReroute(network, reroute)

View File

@@ -1,5 +1,7 @@
// oxlint-disable no-empty-pattern
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, describe, expect, vi } from 'vitest'
import type {
@@ -10,6 +12,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LLink, LinkConnector } from '@/lib/litegraph/src/litegraph'
import { slotFloatingLinks } from '../LLink'
import { test as baseTest } from '../__fixtures__/testExtensions'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import { toLinkId } from '@/types/linkId'
@@ -21,6 +24,7 @@ import {
} from '@/utils/__tests__/litegraphTestUtils'
interface TestContext {
pinia: undefined
graph: LGraph
connector: LinkConnector
setConnectingLinks: (value: ConnectingLink[]) => void
@@ -30,13 +34,21 @@ interface TestContext {
validateIntegrityFloatingRemoved: () => void
validateLinkIntegrity: () => void
getNextLinkIds: (
linkIds: Set<number>,
linkIds: ReadonlySet<number>,
expectedExtraLinks?: number
) => number[]
readonly floatingReroute: Reroute
}
const test = baseTest.extend<TestContext>({
pinia: [
async ({}, use) => {
setActivePinia(createTestingPinia({ stubActions: false }))
await use(undefined)
},
{ auto: true }
],
reroutesBeforeTest: async ({ reroutesComplexGraph }, use) => {
await use([...reroutesComplexGraph.reroutes])
},
@@ -157,18 +169,23 @@ const test = baseTest.extend<TestContext>({
expect(link.origin_id).not.toBe(UNASSIGNED_NODE_ID)
expect(link.origin_slot).not.toBe(-1)
expect(link.target_slot).toBe(-1)
const outputFloatingLinks = graph.getNodeById(link.origin_id)
?.outputs[link.origin_slot]._floatingLinks
expect(outputFloatingLinks).toBeDefined()
const outputFloatingLinks = slotFloatingLinks(
graph,
'output',
link.origin_id,
link.origin_slot
)
expect(outputFloatingLinks).toContain(link)
} else {
expect(link.origin_id).toBe(UNASSIGNED_NODE_ID)
expect(link.origin_slot).toBe(-1)
expect(link.target_slot).not.toBe(-1)
const inputFloatingLinks = graph.getNodeById(link.target_id)?.inputs[
const inputFloatingLinks = slotFloatingLinks(
graph,
'input',
link.target_id,
link.target_slot
]._floatingLinks
expect(inputFloatingLinks).toBeDefined()
)
expect(inputFloatingLinks).toContain(link)
}
}
@@ -229,7 +246,7 @@ describe('LinkConnector Integration', () => {
graph.links.get(hasInputNode.inputs[0].link!)!
)
connector.moveInputLink(graph, hasInputNode.inputs[0])
connector.moveInputLink(graph, hasInputNode, hasInputNode.inputs[0])
expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.renderLinks.length).toBe(1)
@@ -335,9 +352,13 @@ describe('LinkConnector Integration', () => {
expect([0, undefined]).toContain(output.links?.length)
expect([0, undefined]).toContain(input._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'input', toNodeId(nodeId), 0)
).toHaveLength(0)
expect([0, undefined]).toContain(output._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'output', toNodeId(nodeId), 0)
).toHaveLength(0)
}
})
@@ -356,7 +377,7 @@ describe('LinkConnector Integration', () => {
const atOutputNodeEvent = mockedNodeTitleDropEvent(hasOutputNode)
connector.moveInputLink(graph, hasInputNode.inputs[0])
connector.moveInputLink(graph, hasInputNode, hasInputNode.inputs[0])
connector.dropLinks(graph, atOutputNodeEvent)
connector.reset()
@@ -385,7 +406,7 @@ describe('LinkConnector Integration', () => {
const atHasOutputNode = mockedInputDropEvent(hasOutputNode, 0)
connector.moveInputLink(graph, hasInputNode.inputs[0])
connector.moveInputLink(graph, hasInputNode, hasInputNode.inputs[0])
connector.dropLinks(graph, atHasOutputNode)
connector.reset()
@@ -411,7 +432,7 @@ describe('LinkConnector Integration', () => {
?.map((linkId) => graph.links.get(linkId)!)
.map((link) => LLink.getReroutes(graph, link))
connector.moveOutputLink(graph, hasOutputNode.outputs[0])
connector.moveOutputLink(graph, hasOutputNode, hasOutputNode.outputs[0])
expect(connector.state.connectingTo).toBe('output')
expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.renderLinks.length).toBe(3)
@@ -428,7 +449,7 @@ describe('LinkConnector Integration', () => {
expect(connector.outputLinks.length).toBe(0)
expect(disconnectedNode.outputs[0].links).toEqual(nextLinkIds)
expect(hasOutputNode.outputs[0].links).toEqual([])
expect(hasOutputNode.outputs[0].links).toBeNull()
const reroutesAfter = disconnectedNode.outputs[0].links
?.map((linkId) => graph.links.get(linkId)!)
@@ -540,9 +561,13 @@ describe('LinkConnector Integration', () => {
expect([0, undefined]).toContain(output.links?.length)
expect([0, undefined]).toContain(input._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'input', toNodeId(nodeId), 0)
).toHaveLength(0)
expect([0, undefined]).toContain(output._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'output', toNodeId(nodeId), 0)
).toHaveLength(0)
}
})
@@ -560,11 +585,15 @@ describe('LinkConnector Integration', () => {
canvasY
)
connector.moveOutputLink(graph, manyOutputsNode.outputs[0])
connector.moveOutputLink(
graph,
manyOutputsNode,
manyOutputsNode.outputs[0]
)
connector.dropLinks(graph, floatingRerouteEvent)
connector.reset()
expect(manyOutputsNode.outputs[0].links).toEqual([])
expect(manyOutputsNode.outputs[0].links).toBeNull()
expect(floatingReroute.linkIds.size).toBe(4)
validateIntegrityFloatingRemoved()
@@ -585,7 +614,7 @@ describe('LinkConnector Integration', () => {
const canvasY = reroute7.pos[1]
const reroute7Event = createMockCanvasPointerEvent(canvasX, canvasY)
const toSortedRerouteChain = (linkIds: number[]) =>
const toSortedRerouteChain = (linkIds: readonly number[]) =>
linkIds
.map((x) => graph.links.get(toLinkId(x))!)
.map((x) => LLink.getReroutes(graph, x))
@@ -595,7 +624,11 @@ describe('LinkConnector Integration', () => {
manyOutputsNode.outputs[0].links!
)
connector.moveOutputLink(graph, manyOutputsNode.outputs[0])
connector.moveOutputLink(
graph,
manyOutputsNode,
manyOutputsNode.outputs[0]
)
expect(connector.isRerouteValidDrop(reroute7)).toBe(false)
expect(connector.isRerouteValidDrop(reroute10)).toBe(false)
expect(connector.isRerouteValidDrop(reroute13)).toBe(false)
@@ -632,7 +665,7 @@ describe('LinkConnector Integration', () => {
const atInputNodeEvent = mockedNodeTitleDropEvent(hasInputNode)
connector.moveOutputLink(graph, hasOutputNode.outputs[0])
connector.moveOutputLink(graph, hasOutputNode, hasOutputNode.outputs[0])
connector.dropLinks(graph, atInputNodeEvent)
connector.reset()
@@ -670,7 +703,7 @@ describe('LinkConnector Integration', () => {
const atInputNodeOutSlot = mockedOutputDropEvent(hasInputNode, 0)
connector.moveOutputLink(graph, hasOutputNode.outputs[0])
connector.moveOutputLink(graph, hasOutputNode, hasOutputNode.outputs[0])
connector.dropLinks(graph, atInputNodeOutSlot)
connector.reset()
@@ -747,16 +780,20 @@ describe('LinkConnector Integration', () => {
const hasInputNode = graph.getNodeById(toNodeId(2))!
const toInput = hasInputNode.inputs[0]
connector.moveInputLink(graph, fromFloatingInput)
connector.moveInputLink(graph, floatingInputNode, fromFloatingInput)
const dropEvent = mockedInputDropEvent(hasInputNode, 0)
connector.dropLinks(graph, dropEvent)
connector.reset()
expect(fromFloatingInput.link).toBeNull()
expect(fromFloatingInput._floatingLinks?.size).toBe(0)
expect(
slotFloatingLinks(graph, 'input', floatingInputNode.id, 0)
).toHaveLength(0)
expect(toInput.link).toBeNull()
expect(toInput._floatingLinks?.size).toBe(1)
expect(
slotFloatingLinks(graph, 'input', hasInputNode.id, 0)
).toHaveLength(1)
})
test('Allow reroutes to be used as manual switches', ({
@@ -806,7 +843,7 @@ describe('LinkConnector Integration', () => {
validateIntegrityNoChanges
}) => {
const floatingOutNode = graph.getNodeById(toNodeId(1))!
connector.moveOutputLink(graph, floatingOutNode.outputs[0])
connector.moveOutputLink(graph, floatingOutNode, floatingOutNode.outputs[0])
const manyOutputsNode = graph.getNodeById(toNodeId(4))!
const dropEvent = createMockCanvasPointerEvent(
@@ -818,12 +855,14 @@ describe('LinkConnector Integration', () => {
const output = manyOutputsNode.outputs[0]
expect(output.links!.length).toBe(6)
expect(output._floatingLinks!.size).toBe(1)
expect(
slotFloatingLinks(graph, 'output', manyOutputsNode.id, 0)
).toHaveLength(1)
validateIntegrityNoChanges()
// Move again
connector.moveOutputLink(graph, manyOutputsNode.outputs[0])
connector.moveOutputLink(graph, manyOutputsNode, manyOutputsNode.outputs[0])
const disconnectedNode = graph.getNodeById(toNodeId(9))!
const dropEvent2 = createMockCanvasPointerEvent(
@@ -835,13 +874,17 @@ describe('LinkConnector Integration', () => {
const newOutput = disconnectedNode.outputs[0]
expect(newOutput.links!.length).toBe(6)
expect(newOutput._floatingLinks!.size).toBe(1)
expect(
slotFloatingLinks(graph, 'output', disconnectedNode.id, 0)
).toHaveLength(1)
validateIntegrityNoChanges()
disconnectedNode.disconnectOutput(0)
expect(newOutput._floatingLinks!.size).toBe(0)
expect(
slotFloatingLinks(graph, 'output', disconnectedNode.id, 0)
).toHaveLength(0)
expect(graph.floatingLinks.size).toBe(6)
// The final reroutes should all be floating
@@ -867,9 +910,13 @@ describe('LinkConnector Integration', () => {
expect([0, undefined]).toContain(output.links?.length)
expect([0, undefined]).toContain(input._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'input', toNodeId(nodeId), 0)
).toHaveLength(0)
expect([0, undefined]).toContain(output._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'output', toNodeId(nodeId), 0)
).toHaveLength(0)
}
})

View File

@@ -1,6 +1,9 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import type { Reroute } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import {
createMockLGraphNode,
createMockLinkNetwork,
@@ -13,8 +16,19 @@ const mockSetConnectingLinks = vi.fn()
type RenderLinkItem = LinkConnector['renderLinks'][number]
interface MockRenderLinkOptions {
canConnectToOutput?: boolean
canConnectToReroute?: boolean
}
// Mock a structure that has the needed method
function mockRenderLinkImpl(canConnect: boolean): RenderLinkItem {
function mockRenderLinkImpl(
canConnect: boolean,
{
canConnectToOutput = false,
canConnectToReroute = false
}: MockRenderLinkOptions = {}
): RenderLinkItem {
const partial: Partial<RenderLinkItem> = {
toType: 'output',
fromPos: [0, 0],
@@ -25,8 +39,8 @@ function mockRenderLinkImpl(canConnect: boolean): RenderLinkItem {
fromSlot: createMockNodeOutputSlot(),
dragDirection: 0,
canConnectToInput: vi.fn().mockReturnValue(canConnect),
canConnectToOutput: vi.fn().mockReturnValue(false),
canConnectToReroute: vi.fn().mockReturnValue(false),
canConnectToOutput: vi.fn().mockReturnValue(canConnectToOutput),
canConnectToReroute: vi.fn().mockReturnValue(canConnectToReroute),
connectToInput: vi.fn(),
connectToOutput: vi.fn(),
connectToSubgraphInput: vi.fn(),
@@ -73,4 +87,33 @@ describe('LinkConnector', () => {
expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
})
})
describe('dropOnReroute', () => {
test('skips render links that cannot connect to the reroute', () => {
const valid = mockRenderLinkImpl(false, {
canConnectToOutput: true,
canConnectToReroute: true
})
const invalid = mockRenderLinkImpl(false, {
canConnectToOutput: true,
canConnectToReroute: false
})
connector.renderLinks.push(valid, invalid)
connector.state.connectingTo = 'output'
const output = createMockNodeOutputSlot()
const reroute = fromPartial<Reroute>({
findSourceOutput: () => ({ node: mockNode, output })
})
connector.dropOnReroute(reroute, fromPartial<CanvasPointerEvent>({}))
expect(valid.connectToRerouteOutput).toHaveBeenCalledWith(
reroute,
mockNode,
output,
connector.events
)
expect(invalid.connectToRerouteOutput).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,7 +1,8 @@
import { remove } from 'es-toolkit'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import { inputLinkId, outputLinks } from '@/lib/litegraph/src/node/slotLinks'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import {
SUBGRAPH_INPUT_ID,
@@ -135,6 +136,7 @@ export class LinkConnector {
/** Drag an existing link to a different input. */
moveInputLink(
network: LinkNetwork,
node: LGraphNode,
input: INodeInputSlot,
opts?: { startPoint?: Point }
): void {
@@ -142,10 +144,17 @@ export class LinkConnector {
const { state, inputLinks, renderLinks } = this
const linkId = input.link
const linkId = node.graph
? (inputLinkId(node.graph, node.id, node.inputs.indexOf(input)) ?? null)
: null
if (linkId == null) {
// No link connected, check for a floating link
const floatingLink = input._floatingLinks?.values().next().value
const [floatingLink] = slotFloatingLinks(
network,
'input',
node.id,
node.inputs.indexOf(input)
)
if (floatingLink?.parentId == null) return
try {
@@ -270,51 +279,59 @@ export class LinkConnector {
}
/** Drag all links from an output to a new output. */
moveOutputLink(network: LinkNetwork, output: INodeOutputSlot): void {
moveOutputLink(
network: LinkNetwork,
node: LGraphNode,
output: INodeOutputSlot
): void {
if (this.isConnecting) throw new Error('Already dragging links.')
const { state, renderLinks } = this
// Floating links
if (output._floatingLinks?.size) {
for (const floatingLink of output._floatingLinks.values()) {
try {
const reroute = LLink.getFirstReroute(network, floatingLink)
if (!reroute)
throw new Error(
`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`
)
for (const floatingLink of slotFloatingLinks(
network,
'output',
node.id,
node.outputs.indexOf(output)
)) {
try {
const reroute = LLink.getFirstReroute(network, floatingLink)
if (!reroute)
throw new Error(
`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`
)
const renderLink = new FloatingRenderLink(
network,
floatingLink,
'output',
reroute
)
const mayContinue = this.events.dispatch(
'before-move-output',
renderLink
)
if (mayContinue === false) continue
const renderLink = new FloatingRenderLink(
network,
floatingLink,
'output',
reroute
)
const mayContinue = this.events.dispatch(
'before-move-output',
renderLink
)
if (mayContinue === false) continue
renderLinks.push(renderLink)
this.floatingLinks.push(floatingLink)
} catch (error) {
console.warn(
`Could not create render link for link id: [${floatingLink.id}].`,
floatingLink,
error
)
}
renderLinks.push(renderLink)
this.floatingLinks.push(floatingLink)
} catch (error) {
console.warn(
`Could not create render link for link id: [${floatingLink.id}].`,
floatingLink,
error
)
}
}
// Normal links
if (output.links?.length) {
for (const linkId of output.links) {
const link = network.links.get(linkId)
if (!link) continue
if (node.graph) {
for (const link of outputLinks(
node.graph,
node.id,
node.outputs.indexOf(output)
)) {
const firstReroute = LLink.getFirstReroute(network, link)
if (firstReroute) {
firstReroute._dragging = true
@@ -806,16 +823,20 @@ export class LinkConnector {
return
}
// Connecting to output
for (const link of this.renderLinks) {
if (link.toType !== 'output') continue
const result = reroute.findSourceOutput()
if (!result) continue
const { node, output } = result
if (!link.canConnectToOutput(node, output)) continue
// Connecting to output. Resolve the source and each link's validity once,
// before connecting: re-anchoring the first link's chain onto the reroute
// changes its membership and origin immediately.
const result = reroute.findSourceOutput()
if (!result) return
const { node, output } = result
const connectable = this.renderLinks.filter(
(link) =>
link.toType === 'output' &&
link.canConnectToReroute(reroute) &&
link.canConnectToOutput(node, output)
)
for (const link of connectable) {
link.connectToRerouteOutput(reroute, node, output, this.events)
}
}
@@ -832,20 +853,9 @@ export class LinkConnector {
// From reroute to reroute
if (renderLink instanceof ToInputRenderLink) {
const { node, fromSlot, fromSlotIndex, fromReroute } = renderLink
const { node, fromSlotIndex } = renderLink
reroute.setFloatingLinkOrigin(node, fromSlot, fromSlotIndex)
// Clean floating link IDs from reroutes about to be removed from the chain
if (fromReroute != null) {
for (const originalReroute of originalReroutes) {
if (originalReroute.id === fromReroute.id) break
for (const linkId of reroute.floatingLinkIds) {
originalReroute.floatingLinkIds.delete(linkId)
}
}
}
reroute.setFloatingLinkOrigin(node, fromSlotIndex)
}
// Filter before any connections are re-created

View File

@@ -1,4 +1,6 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -31,6 +33,7 @@ describe('LinkConnector SubgraphInput connection validation', () => {
const mockSetConnectingLinks = vi.fn()
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
connector = new LinkConnector(mockSetConnectingLinks)
vi.clearAllMocks()
})

View File

@@ -120,7 +120,6 @@ export class ToInputFromIoNodeLink implements RenderLink {
for (const reroute of originalReroutes) {
if (reroute.id === fromReroute?.id) break
reroute.removeLink(link)
if (reroute.totalLinks === 0) {
if (link.isFloating) {
// Cannot float from both sides - remove

View File

@@ -112,7 +112,6 @@ export class ToInputRenderLink implements RenderLink {
for (const reroute of originalReroutes) {
if (reroute.id === fromReroute?.id) break
reroute.removeLink(link)
if (reroute.totalLinks === 0) {
if (link.isFloating) {
// Cannot float from both sides - remove

View File

@@ -183,6 +183,8 @@ export interface LinkNetwork extends ReadonlyLinkNetwork {
readonly reroutes: Map<RerouteId, Reroute>
addFloatingLink(link: LLink): LLink
removeReroute(id: RerouteId): unknown
/** Removes a reroute from the map and its stores, without chain splicing. */
_removeReroute(id: RerouteId): void
removeFloatingLink(link: LLink): void
}
@@ -329,11 +331,6 @@ export interface INodeSlot extends HasBoundingRect {
pos?: Point
/** @remarks Automatically calculated; not included in serialisation. */
boundingRect: ReadOnlyRect
/**
* A list of floating link IDs that are connected to this slot.
* This is calculated at runtime; it is **not** serialized.
*/
_floatingLinks?: Set<LLink>
/**
* Whether the slot has errors. It is **not** serialized.
*/
@@ -363,7 +360,13 @@ export interface IWidgetLocator {
}
export interface INodeInputSlot extends INodeSlot {
link: LinkId | null
/**
* @deprecated Id of the link targeting this slot, derived from the link
* store by a warning getter. Read via `node.isInputConnected(slot)` /
* `node.getInputLink(slot)`; mutate via `node.connect()` /
* `node.disconnectInput()`.
*/
readonly link?: LinkId | null
widget?: IWidgetLocator
widgetId?: WidgetId
alwaysVisible?: boolean
@@ -379,7 +382,13 @@ export interface IWidgetInputSlot extends INodeInputSlot {
}
export interface INodeOutputSlot extends INodeSlot {
links: LinkId[] | null
/**
* @deprecated Ids of the links leaving this slot, derived from the link
* store by a warning getter. Read via `node.isOutputConnected(slot)` /
* `node.getOutputNodes(slot)`; mutate via `node.connect()` /
* `node.disconnectOutput()`.
*/
readonly links?: readonly LinkId[] | null
_data?: unknown
slot_index?: SlotIndex
}

View File

@@ -1,6 +1,11 @@
import { toLinkId } from '@/types/linkId'
import { registerLinkTopology } from './LLink'
import { inputLinkId } from './node/slotLinks'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import type { SerializedNodeId } from '@/types/nodeId'
import type { LLink, LinkId } from './LLink'
import type { ISerialisedNode } from './types/serialisation'
/** Generates a unique string key for a link's connection tuple. */
function linkTupleKey(link: LLink): string {
@@ -29,55 +34,60 @@ export function selectSurvivorLink(
ids: LinkId[],
node: LGraphNode | null
): LinkId {
if (!node) return ids[0]
if (!node?.graph) return ids[0]
for (const input of node.inputs ?? []) {
if (!input) continue
const match = ids.find((id) => input.link === id)
if (match != null) return match
for (const [index] of (node.inputs ?? []).entries()) {
const registered = inputLinkId(node.graph, node.id, index)
if (registered != null && ids.includes(registered)) return registered
}
return ids[0]
}
/** Removes duplicate links from origin outputs and the graph's link map. */
/**
* Removes duplicate links from origin outputs and the graph, routing map
* removal through {@link LGraph._removeLink} so the link and layout stores
* stay in sync.
*/
export function purgeOrphanedLinks(
ids: LinkId[],
keepId: LinkId,
links: Map<LinkId, LLink>,
getNodeById: (id: SerializedNodeId) => LGraphNode | null
graph: LGraph
): void {
for (const id of ids) {
if (id === keepId) continue
const link = links.get(id)
const link = graph._links.get(id)
if (!link) continue
const originNode = getNodeById(link.origin_id)
const output = originNode?.outputs?.[link.origin_slot]
if (output?.links) {
for (let i = output.links.length - 1; i >= 0; i--) {
if (output.links[i] === id) output.links.splice(i, 1)
}
}
links.delete(id)
graph._removeLink(id)
}
// Purging a duplicate that owned the survivor's target-slot index entry
// removes that entry, so re-assert the survivor's registration afterwards.
const survivor = graph._links.get(keepId)
if (survivor) registerLinkTopology(graph, survivor)
}
/** Ensures input.link on the target node points to the surviving link. */
export function repairInputLinks(
ids: LinkId[],
keepId: LinkId,
node: LGraphNode | null
/**
* Re-points each link's `target_slot` at the index of the serialized input
* that references it. Node `configure()` overrides may reorder a node's
* serialized inputs in place to match the current node definition (e.g.
* widget-to-input conversions, Comfy-Org/ComfyUI_frontend#3348), invalidating
* the slot indices stored on links.
*
* @param graph The graph whose links to realign
* @param nodesData The serialized node data the graph's nodes were configured
* from, after any in-place input reordering by node `configure()` overrides
*/
export function realignInputLinkSlots(
graph: LGraph,
nodesData: Iterable<ISerialisedNode>
): void {
if (!node) return
const duplicateIds = new Set(ids)
for (const input of node.inputs ?? []) {
if (input?.link == null || input.link === keepId) continue
if (duplicateIds.has(input.link)) {
input.link = keepId
for (const nodeData of nodesData) {
for (const [slot, input] of (nodeData.inputs ?? []).entries()) {
if (input.link == null) continue
const link = graph._links.get(toLinkId(input.link))
if (link && link.target_slot !== slot) link.target_slot = slot
}
}
}

View File

@@ -114,7 +114,7 @@ export {
type SubgraphId
} from './LGraph'
export type { LGraphTriggerEvent } from './types/graphTriggers'
export { BadgePosition, LGraphBadge } from './LGraphBadge'
export { LGraphBadge } from './LGraphBadge'
export { LGraphCanvas } from './LGraphCanvas'
export { LGraphGroup, type GroupId } from './LGraphGroup'
export { LGraphNode } from './LGraphNode'

View File

@@ -9,6 +9,8 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import { inputHasLink, inputLinkId } from '@/lib/litegraph/src/node/slotLinks'
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
import type { IDrawOptions } from '@/lib/litegraph/src/node/NodeSlot'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
@@ -16,7 +18,8 @@ import { isSubgraphInput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
link: LinkId | null
/** @deprecated Derived from the link store via a warning prototype getter; never written. */
declare readonly link?: LinkId | null
alwaysVisible?: boolean
get isWidgetInputSlot(): boolean {
@@ -42,12 +45,17 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
slot: OptionalProps<INodeInputSlot, 'boundingRect'>,
node: LGraphNode
) {
super(slot, node)
this.link = slot.link
// Serialized inputs carry a legacy link mirror; strip it so the base
// ctor's Object.assign cannot collide with the deprecated prototype
// getter (assigning a getter-only property throws in strict mode).
const { link: _legacyLink, ...rest } = slot
super(rest, node)
}
override get isConnected(): boolean {
return this.link != null
const { graph } = this._node
if (!graph) return false
return inputHasLink(graph, this._node.id, this._node.inputs.indexOf(this))
}
override isValidTarget(
@@ -81,10 +89,40 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
}
override toJSON(): INodeInputSlot {
const { graph } = this._node
return {
...super.toJSON(),
link: this.link,
link: graph
? (inputLinkId(graph, this._node.id, this._node.inputs.indexOf(this)) ??
null)
: null,
widget: this.widget
}
}
}
/**
* Deprecation telemetry for extensions that still touch `input.link`.
* Reads return the store-derived link id; writes fire telemetry and are
* ignored, since the store cannot be mutated through the mirror.
* First-party code uses the slotLinks helpers.
*/
Object.defineProperty(NodeInputSlot.prototype, 'link', {
get(this: NodeInputSlot): LinkId | null {
warnDeprecated(
'input.link is deprecated. Read connectivity via node.isInputConnected(slot) / node.getInputLink(slot); mutate via node.connect() / node.disconnectInput().'
)
const { graph } = this._node
if (!graph) return null
return (
inputLinkId(graph, this._node.id, this._node.inputs.indexOf(this)) ?? null
)
},
set(this: NodeInputSlot): void {
warnDeprecated(
'Assignment to input.link is deprecated and has no effect; connectivity is derived from the link store. Mutate via node.connect() / node.disconnectInput().'
)
},
configurable: true,
enumerable: false
})

View File

@@ -10,12 +10,18 @@ import type {
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { IDrawOptions } from '@/lib/litegraph/src/node/NodeSlot'
import {
outputHasLinks,
outputLinkIds
} from '@/lib/litegraph/src/node/slotLinks'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import { isSubgraphOutput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
links: LinkId[] | null
/** @deprecated Derived from the link store via a warning prototype getter; never written. */
declare readonly links?: readonly LinkId[] | null
_data?: unknown
slot_index?: number
@@ -34,8 +40,11 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
slot: OptionalProps<INodeOutputSlot, 'boundingRect'>,
node: LGraphNode
) {
super(slot, node)
this.links = slot.links
// Serialized outputs carry a legacy links mirror; strip it so the base
// ctor's Object.assign cannot collide with the deprecated prototype
// getter (assigning a getter-only property throws in strict mode).
const { links: _legacyLinks, ...rest } = slot
super(rest, node)
this._data = slot._data
this.slot_index = slot.slot_index
}
@@ -55,7 +64,13 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
}
override get isConnected(): boolean {
return this.links != null && this.links.length > 0
const { graph } = this._node
if (!graph) return false
return outputHasLinks(
graph,
this._node.id,
this._node.outputs.indexOf(this)
)
}
override draw(
@@ -77,10 +92,43 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
}
override toJSON(): INodeOutputSlot {
const { graph } = this._node
const ids = graph
? outputLinkIds(graph, this._node.id, this._node.outputs.indexOf(this))
: []
return {
...super.toJSON(),
links: this.links,
links: ids.length ? ids : null,
slot_index: this.slot_index
}
}
}
/**
* Deprecation telemetry for extensions that still touch `output.links`.
* Reads return a fresh store-derived array; writes fire telemetry and are
* ignored, since the store cannot be mutated through the mirror.
* First-party code uses the slotLinks helpers.
*/
Object.defineProperty(NodeOutputSlot.prototype, 'links', {
get(this: NodeOutputSlot): LinkId[] | null {
warnDeprecated(
'output.links is deprecated. Read connectivity via node.isOutputConnected(slot) / node.getOutputNodes(slot); mutate via node.connect() / node.disconnectOutput().'
)
const { graph } = this._node
if (!graph) return null
const ids = outputLinkIds(
graph,
this._node.id,
this._node.outputs.indexOf(this)
)
return ids.length ? ids : null
},
set(this: NodeOutputSlot): void {
warnDeprecated(
'Assignment to output.links is deprecated and has no effect; connectivity is derived from the link store. Mutate via node.connect() / node.disconnectOutput().'
)
},
configurable: true,
enumerable: false
})

View File

@@ -0,0 +1,123 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
function createConnectedPair() {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'INT')
const target = new LGraphNode('Target')
target.addInput('in', 'INT')
graph.add(source)
graph.add(target)
const link = source.connect(0, target, 0)!
return { graph, source, target, link }
}
describe('deprecated slot link mirrors', () => {
const deprecationCallback = vi.fn()
const originalCallbacks = LiteGraph.onDeprecationWarning
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
deprecationCallback.mockClear()
LiteGraph.onDeprecationWarning = [deprecationCallback]
LiteGraph.alwaysRepeatWarnings = true
})
afterEach(() => {
LiteGraph.onDeprecationWarning = originalCallbacks
LiteGraph.alwaysRepeatWarnings = false
})
describe('NodeInputSlot.link', () => {
it('returns the link id for a connected input and warns', () => {
const { target, link } = createConnectedPair()
expect(target.inputs[0].link).toBe(link.id)
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('input.link is deprecated'),
undefined
)
})
it('returns null for a disconnected input and warns', () => {
const { target } = createConnectedPair()
target.disconnectInput(0)
expect(target.inputs[0].link).toBeNull()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('input.link is deprecated'),
undefined
)
})
it('returns null for an input on a node detached from any graph', () => {
const node = new LGraphNode('Detached')
node.addInput('in', 'INT')
expect(node.inputs[0].link).toBeNull()
})
it('ignores writes, warns, and keeps the store-derived value', () => {
const { target, link } = createConnectedPair()
const input: { link?: LinkId | null } = target.inputs[0]
expect(() => {
input.link = null
}).not.toThrow()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('Assignment to input.link is deprecated'),
undefined
)
expect(target.inputs[0].link).toBe(link.id)
})
})
describe('NodeOutputSlot.links', () => {
it('returns the link ids for a connected output and warns', () => {
const { source, link } = createConnectedPair()
expect(source.outputs[0].links).toEqual([link.id])
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('output.links is deprecated'),
undefined
)
})
it('returns null for a disconnected output and warns', () => {
const { source } = createConnectedPair()
source.disconnectOutput(0)
expect(source.outputs[0].links).toBeNull()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('output.links is deprecated'),
undefined
)
})
it('returns null for an output on a node detached from any graph', () => {
const node = new LGraphNode('Detached')
node.addOutput('out', 'INT')
expect(node.outputs[0].links).toBeNull()
})
it('ignores writes, warns, and keeps the store-derived value', () => {
const { source, link } = createConnectedPair()
const output: { links?: readonly LinkId[] | null } = source.outputs[0]
expect(() => {
output.links = null
}).not.toThrow()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringContaining('Assignment to output.links is deprecated'),
undefined
)
expect(source.outputs[0].links).toEqual([link.id])
})
})
})

View File

@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
import type {
INodeInputSlot,
INodeOutputSlot
INodeOutputSlot,
IWidget
} from '@/lib/litegraph/src/litegraph'
import {
LGraphNode,
inputAsSerialisable,
outputAsSerialisable
} from '@/lib/litegraph/src/litegraph'
@@ -22,12 +24,17 @@ describe('NodeSlot', () => {
links: [],
boundingRect
}
// @ts-expect-error Argument type mismatch for test
const serialized = outputAsSerialisable(slot)
const node = new LGraphNode('test')
const serialized = outputAsSerialisable(
slot as INodeOutputSlot & { widget?: IWidget },
node,
0
)
expect(serialized).not.toHaveProperty('_data')
})
it('removes pos from widget input slots', () => {
const node = new LGraphNode('test')
// Minimal slot for serialization test - boundingRect is calculated at runtime, not serialized
const widgetInputSlot: INodeInputSlot = {
name: 'test-id',
@@ -38,11 +45,12 @@ describe('NodeSlot', () => {
boundingRect
}
const serialized = inputAsSerialisable(widgetInputSlot)
const serialized = inputAsSerialisable(widgetInputSlot, node, 0)
expect(serialized).not.toHaveProperty('pos')
})
it('preserves pos for non-widget input slots', () => {
const node = new LGraphNode('test')
const normalSlot: INodeInputSlot = {
name: 'test-id',
type: 'STRING',
@@ -50,11 +58,12 @@ describe('NodeSlot', () => {
link: null,
boundingRect
}
const serialized = inputAsSerialisable(normalSlot)
const serialized = inputAsSerialisable(normalSlot, node, 0)
expect(serialized).toHaveProperty('pos')
})
it('preserves only widget name during serialization', () => {
const node = new LGraphNode('test')
// Extra widget properties simulate real data that should be stripped during serialization
const widgetInputSlot: INodeInputSlot = {
name: 'test-id',
@@ -67,7 +76,7 @@ describe('NodeSlot', () => {
}
}
const serialized = inputAsSerialisable(widgetInputSlot)
const serialized = inputAsSerialisable(widgetInputSlot, node, 0)
expect(serialized.widget).toEqual({ name: 'test-widget' })
expect(serialized.widget).not.toHaveProperty('type')
expect(serialized.widget).not.toHaveProperty('value')

View File

@@ -1,4 +1,3 @@
import type { LLink } from '@/lib/litegraph/src/LLink'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type {
CanvasColour,
@@ -28,7 +27,6 @@ export abstract class SlotBase implements INodeSlot {
locked?: boolean
nameLocked?: boolean
widget?: IWidgetLocator
_floatingLinks?: Set<LLink>
hasErrors?: boolean
/** The centre point of the slot. */

View File

@@ -0,0 +1,174 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
function duckInputSlot(): INodeInputSlot {
return { name: 'in', type: 'INT', link: null, boundingRect: [0, 0, 0, 0] }
}
function createSourceAndTarget() {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'INT')
graph.add(source)
const target = new LGraphNode('Target')
graph.add(target)
return { graph, source, target }
}
function mockCanvasContext() {
return {
fillStyle: '',
beginPath: vi.fn(),
closePath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
rect: vi.fn(),
arc: vi.fn(),
fill: vi.fn()
}
}
describe('ecosystem slot patterns', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
LiteGraph.alwaysRepeatWarnings = true
})
afterEach(() => {
LiteGraph.alwaysRepeatWarnings = false
LiteGraph.onDeprecationWarning = []
})
describe('duck-typed slots wrapped by _setConcreteSlots (M2)', () => {
it('renders a connected duck-typed input in collapsed mode', () => {
const { source, target } = createSourceAndTarget()
target.inputs.push(duckInputSlot())
source.connect(0, target, 0)
target._setConcreteSlots()
const ctx = mockCanvasContext()
target.drawCollapsedSlots(ctx as unknown as CanvasRenderingContext2D)
expect(ctx.fill).toHaveBeenCalledTimes(1)
})
it('upgrades duck-typed entries in node.inputs to concrete slots', () => {
const { source, target } = createSourceAndTarget()
target.inputs.push(duckInputSlot())
source.connect(0, target, 0)
target._setConcreteSlots()
expect(target.inputs[0]).toBeInstanceOf(NodeInputSlot)
expect((target.inputs[0] as NodeInputSlot).isConnected).toBe(true)
})
})
describe("input literals without a 'link' key (M3)", () => {
it('does not report a free input as occupied because the same-index output is connected', () => {
const { source, target } = createSourceAndTarget()
target.addInput('in', 'INT')
source.connect(0, target, 0)
const linkless: Omit<INodeInputSlot, 'link'> = {
name: 'extra',
type: 'INT',
boundingRect: [0, 0, 0, 0]
}
source.inputs.push(linkless)
// source: input 0 is a free linkless literal; output 0 is connected.
expect(source.findInputSlotFree()).toBe(0)
})
it('does not report a connected linkless input literal as free', () => {
const { source, target } = createSourceAndTarget()
const linkless: Omit<INodeInputSlot, 'link'> = {
name: 'in',
type: 'INT',
boundingRect: [0, 0, 0, 0]
}
target.inputs.push(linkless)
source.connect(0, target, 0)
expect(target.findInputSlotFree()).toBe(-1)
})
})
describe('addInput / addOutput with legacy mirror keys (M5a)', () => {
it('accepts extra_info that still carries a link value', () => {
const node = new LGraphNode('n')
expect(() => node.addInput('in', 'INT', { link: null })).not.toThrow()
expect(node.inputs).toHaveLength(1)
expect(node.inputs[0].name).toBe('in')
})
it('accepts extra_info that still carries a links value', () => {
const node = new LGraphNode('n')
expect(() =>
node.addOutput('out', 'INT', { links: null, label: 'Out' })
).not.toThrow()
expect(node.outputs).toHaveLength(1)
expect(node.outputs[0].label).toBe('Out')
})
})
describe('direct writes to the deprecated mirrors (M5b)', () => {
it('ignores input.link writes and fires telemetry', () => {
const deprecationCallback = vi.fn()
LiteGraph.onDeprecationWarning = [deprecationCallback]
const node = new LGraphNode('n')
const input = node.addInput('in', 'INT')
const legacyWriter = input as { link?: unknown }
expect(() => {
legacyWriter.link = 42
}).not.toThrow()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringMatching(/input\.link.*connect\(\).*disconnectInput\(\)/),
undefined
)
expect(input.link).toBeNull()
})
it('ignores output.links writes and fires telemetry', () => {
const deprecationCallback = vi.fn()
LiteGraph.onDeprecationWarning = [deprecationCallback]
const node = new LGraphNode('n')
const output = node.addOutput('out', 'INT')
const legacyWriter = output as { links?: unknown }
expect(() => {
legacyWriter.links = [1]
}).not.toThrow()
expect(deprecationCallback).toHaveBeenCalledWith(
expect.stringMatching(
/output\.links.*connect\(\).*disconnectOutput\(\)/
),
undefined
)
expect(output.links).toBeNull()
})
})
describe('plain-object slot reads after concretisation (M6a)', () => {
it('reads the store-derived link id through the upgraded slot', () => {
const { graph, source, target } = createSourceAndTarget()
target.inputs.push(duckInputSlot())
const link = source.connect(0, target, 0)
target._setConcreteSlots()
expect(link).not.toBeNull()
expect(target.inputs[0].link).toBe(link!.id)
expect(graph.getLink(target.inputs[0].link!)).toBe(link)
})
})
})

View File

@@ -0,0 +1,144 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { createTestSubgraph } from '../subgraph/__fixtures__/subgraphHelpers'
import {
inputHasLink,
inputLink,
inputLinkId,
outputHasLinks,
outputLinkIds,
outputLinks
} from './slotLinks'
function createConnectedGraph(targetCount: number) {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'INT')
graph.add(source)
const targets = Array.from({ length: targetCount }, (_, i) => {
const target = new LGraphNode(`Target${i}`)
target.addInput('in', 'INT')
graph.add(target)
source.connect(0, target, 0)
return target
})
return { graph, source, targets }
}
describe('slotLinks', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('reports presence, ids, and resolved links for an output slot', () => {
const { graph, source } = createConnectedGraph(2)
expect(outputHasLinks(graph, source.id, 0)).toBe(true)
const ids = outputLinkIds(graph, source.id, 0)
expect(ids).toHaveLength(2)
expect([...ids]).toEqual([...ids].sort((a, b) => a - b))
expect(outputLinks(graph, source.id, 0).map((l) => l.id)).toEqual(ids)
})
it('returns nothing for an unconnected slot', () => {
const { graph, source } = createConnectedGraph(0)
expect(outputHasLinks(graph, source.id, 0)).toBe(false)
expect(outputLinkIds(graph, source.id, 0)).toEqual([])
expect(outputLinks(graph, source.id, 0)).toEqual([])
})
it('never includes floating links', () => {
const { graph, source, targets } = createConnectedGraph(1)
const link = inputLink(graph, targets[0].id, 0)!
const reroute = graph.createReroute([0, 0], link)!
graph.remove(targets[0])
expect(graph.floatingLinks.size).toBe(1)
expect(reroute.floatingLinkIds.size).toBe(1)
expect(outputHasLinks(graph, source.id, 0)).toBe(false)
expect(outputLinks(graph, source.id, 0)).toEqual([])
})
it('reports presence, id, and the resolved link for an input slot', () => {
const { graph, targets } = createConnectedGraph(1)
const target = targets[0]
expect(inputHasLink(graph, target.id, 0)).toBe(true)
const id = inputLinkId(graph, target.id, 0)
const link = inputLink(graph, target.id, 0)
expect(link?.id).toBe(id)
expect(link?.target_id).toBe(target.id)
expect(link?.target_slot).toBe(0)
})
it('returns nothing for an unconnected input slot', () => {
const { graph, targets } = createConnectedGraph(1)
const target = targets[0]
target.disconnectInput(0)
expect(inputHasLink(graph, target.id, 0)).toBe(false)
expect(inputLinkId(graph, target.id, 0)).toBeUndefined()
expect(inputLink(graph, target.id, 0)).toBeUndefined()
})
it('never reports a floating link on the input side', () => {
const { graph, source, targets } = createConnectedGraph(1)
const link = inputLink(graph, targets[0].id, 0)!
graph.createReroute([0, 0], link)
graph.remove(source)
expect(graph.floatingLinks.size).toBe(1)
expect(inputHasLink(graph, targets[0].id, 0)).toBe(false)
expect(inputLink(graph, targets[0].id, 0)).toBeUndefined()
})
it('resolves links inside a subgraph', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
expect(inputHasLink(subgraph, second.id, 0)).toBe(true)
expect(inputLinkId(subgraph, second.id, 0)).toBe(innerLink.id)
expect(inputLink(subgraph, second.id, 0)).toBe(innerLink)
})
it('reads empty during the INPUT callback of a disconnect', () => {
const { graph, targets } = createConnectedGraph(1)
const target = targets[0]
const seen: boolean[] = []
target.onConnectionsChange = vi.fn(
(type: NodeSlotType, _slot: number, connected: boolean) => {
if (type === NodeSlotType.INPUT && !connected) {
seen.push(inputHasLink(graph, target.id, 0))
}
}
)
target.disconnectInput(0)
expect(seen).toEqual([false])
})
it('reads empty during the final OUTPUT callback of a disconnect-all', () => {
const { graph, source } = createConnectedGraph(2)
const seen: boolean[] = []
source.onConnectionsChange = vi.fn(
(type: NodeSlotType, _slot: number, connected: boolean) => {
if (type === NodeSlotType.OUTPUT && !connected) {
seen.push(outputHasLinks(graph, source.id, 0))
}
}
)
source.disconnectOutput(0)
expect(seen).toEqual([true, false])
})
})

View File

@@ -0,0 +1,78 @@
import { useLinkStore } from '@/stores/linkStore'
import type { LinkId } from '@/types/linkId'
import type { NodeId } from '@/types/nodeId'
import type { LGraph } from '../LGraph'
import type { LLink } from '../LLink'
/**
* Store-backed reads over the links attached to a node's slots.
* These replace the `output.links[]` / `input.link` mirrors: the link
* store is the source of truth, and floating links are never included.
*/
/** True when a link targets the input slot. */
export function inputHasLink(
graph: Pick<LGraph, 'rootGraph'>,
nodeId: NodeId,
slot: number
): boolean {
return useLinkStore().isInputSlotConnected(graph.rootGraph.id, nodeId, slot)
}
/** Id of the link targeting an input slot, if any. */
export function inputLinkId(
graph: Pick<LGraph, 'rootGraph'>,
nodeId: NodeId,
slot: number
): LinkId | undefined {
return useLinkStore().getInputSlotLink(graph.rootGraph.id, nodeId, slot)?.id
}
/** The link targeting an input slot, resolved in the owning graph. */
export function inputLink(
graph: LGraph,
nodeId: NodeId,
slot: number
): LLink | undefined {
const id = inputLinkId(graph, nodeId, slot)
return id === undefined ? undefined : graph.getLink(id)
}
/** True when at least one link leaves the output slot. */
export function outputHasLinks(
graph: Pick<LGraph, 'rootGraph'>,
nodeId: NodeId,
slot: number
): boolean {
return useLinkStore().isOutputSlotConnected(graph.rootGraph.id, nodeId, slot)
}
/** Ids of the links leaving an output slot, in ascending id order. */
export function outputLinkIds(
graph: Pick<LGraph, 'rootGraph'>,
nodeId: NodeId,
slot: number
): LinkId[] {
const ids = [
...useLinkStore().getOutputSlotLinks(graph.rootGraph.id, nodeId, slot)
].map((topology) => topology.id)
return ids.sort((a, b) => a - b)
}
/**
* Snapshot of the links leaving an output slot, resolved in the owning
* graph. Safe to disconnect links while iterating the result.
*/
export function outputLinks(
graph: LGraph,
nodeId: NodeId,
slot: number
): LLink[] {
const links: LLink[] = []
for (const id of outputLinkIds(graph, nodeId, slot)) {
const link = graph.getLink(id)
if (link) links.push(link)
}
return links
}

View File

@@ -1,36 +1,81 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
import type { IWidget } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { outputAsSerialisable } from './slotUtils'
type OutputSlotParam = INodeOutputSlot & { widget?: IWidget }
function createConnectedGraph(targetCount: number) {
const graph = new LGraph()
const source = new LGraphNode('Source')
source.addOutput('out', 'number')
graph.add(source)
for (let i = 0; i < targetCount; i++) {
const target = new LGraphNode(`Target${i}`)
target.addInput('in', 'number')
graph.add(target)
source.connect(0, target, 0)
}
return { graph, source }
}
describe('outputAsSerialisable', () => {
it('clones the links array to prevent shared reference mutation', () => {
const node = new LGraphNode('test')
const output = node.addOutput('out', 'number')
output.links = [toLinkId(1), toLinkId(2), toLinkId(3)]
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
const serialised = outputAsSerialisable(output as OutputSlotParam)
it('serialises the links leaving the slot, ascending by id', () => {
const { source } = createConnectedGraph(3)
expect(serialised.links).toEqual([1, 2, 3])
expect(serialised.links).not.toBe(output.links)
const serialised = outputAsSerialisable(
source.outputs[0] as OutputSlotParam,
source,
0
)
// Mutating the live array should NOT affect the serialised copy
output.links.push(toLinkId(4))
expect(serialised.links).toHaveLength(3)
expect(serialised.links).toEqual([...serialised.links!].sort())
})
it('preserves null links', () => {
const node = new LGraphNode('test')
const output = node.addOutput('out', 'number')
output.links = null
it('returns a snapshot unaffected by later graph changes', () => {
const { source } = createConnectedGraph(2)
const serialised = outputAsSerialisable(output as OutputSlotParam)
const serialised = outputAsSerialisable(
source.outputs[0] as OutputSlotParam,
source,
0
)
expect(serialised.links).toHaveLength(2)
source.disconnectOutput(0)
expect(serialised.links).toHaveLength(2)
})
it('serialises null when the slot has no links', () => {
const { source } = createConnectedGraph(0)
const serialised = outputAsSerialisable(
source.outputs[0] as OutputSlotParam,
source,
0
)
expect(serialised.links).toBeNull()
})
it('serialises null for a node with no graph', () => {
const node = new LGraphNode('Detached')
node.addOutput('out', 'number')
const serialised = outputAsSerialisable(
node.outputs[0] as OutputSlotParam,
node,
0
)
expect(serialised.links).toBeNull()
})
})

View File

@@ -1,3 +1,4 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
IWidgetInputSlot,
SharedIntersection
@@ -5,9 +6,9 @@ import type {
import type {
INodeInputSlot,
INodeOutputSlot,
INodeSlot,
IWidget
} from '@/lib/litegraph/src/litegraph'
import { inputLinkId, outputLinkIds } from '@/lib/litegraph/src/node/slotLinks'
import type {
ISerialisableNodeInput,
ISerialisableNodeOutput
@@ -48,12 +49,16 @@ function shallowCloneCommonProps(slot: CommonIoSlotProps): CommonIoSlotProps {
}
export function inputAsSerialisable(
slot: INodeInputSlot
slot: INodeInputSlot,
node: LGraphNode,
slotIndex: number
): ISerialisableNodeInput {
const { link } = slot
const widgetOrPos = slot.widget
? { widget: { name: slot.widget.name } }
: { pos: slot.pos }
const link = node.graph
? (inputLinkId(node.graph, node.id, slotIndex) ?? null)
: null
return {
...shallowCloneCommonProps(slot),
@@ -63,25 +68,24 @@ export function inputAsSerialisable(
}
export function outputAsSerialisable(
slot: INodeOutputSlot & { widget?: IWidget }
slot: INodeOutputSlot & { widget?: IWidget },
node: LGraphNode,
slotIndex: number
): ISerialisableNodeOutput {
const { pos, slot_index, links, widget } = slot
const { pos, slot_index, widget } = slot
// Output widgets do not exist in Litegraph; this is a temporary downstream workaround.
const outputWidget = widget ? { widget: { name: widget.name } } : null
const ids = node.graph ? outputLinkIds(node.graph, node.id, slotIndex) : []
return {
...shallowCloneCommonProps(slot),
...outputWidget,
pos,
slot_index,
links: links ? [...links] : links
links: ids.length ? ids : null
}
}
export function isINodeInputSlot(slot: INodeSlot): slot is INodeInputSlot {
return 'link' in slot
}
/**
* Type guard: Whether this input slot is attached to a widget.
* @param slot The slot to check.

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import type { BadgeData } from '@/types/badgeData'
import { registerBadgeIcon } from './badgeIconRegistry'
import { badgeDrawObjects } from './nodeBadgeDraw'
function coreRow(part: BadgeData['part'], text: string): BadgeData {
return { kind: 'core', part, text, fgColor: '#fff', bgColor: '#000' }
}
describe('badgeDrawObjects', () => {
it('joins core parts into one badge in id, lifecycle, source order', () => {
const badges = badgeDrawObjects({}, [
coreRow('lifecycle', '[BETA]'),
coreRow('id', '#5'),
coreRow('source', 'my-pack')
])
expect(badges).toHaveLength(1)
expect(badges[0].text).toBe('#5 [BETA] my-pack')
expect(badges[0].fgColor).toBe('#fff')
expect(badges[0].bgColor).toBe('#000')
})
it('truncates the joined core text', () => {
const badges = badgeDrawObjects({}, [
coreRow('source', 'a'.repeat(40)),
coreRow('id', '#5')
])
expect(badges[0].text).toHaveLength(31)
expect(badges[0].text.endsWith('...')).toBe(true)
})
it('draws credits rows separately with their registered icon', () => {
registerBadgeIcon('test-icon', { unicode: 'X', fontFamily: 'test' })
const badges = badgeDrawObjects({}, [
coreRow('id', '#5'),
{
kind: 'credits',
text: '$0.04',
iconKey: 'test-icon',
fgColor: '#fff',
bgColor: '#8D6932'
}
])
expect(badges).toHaveLength(2)
expect(badges[1].text).toBe('$0.04')
expect(badges[1].bgColor).toBe('#8D6932')
expect(badges[1].icon?.unicode).toBe('X')
})
it('keeps an unknown icon key iconless', () => {
const badges = badgeDrawObjects({}, [
{ kind: 'credits', text: '$1', iconKey: 'nope' }
])
expect(badges[0].icon).toBeUndefined()
})
it('reuses draw objects until the rows change content', () => {
const node = {}
const rows: BadgeData[] = [coreRow('id', '#5')]
const first = badgeDrawObjects(node, rows)
const second = badgeDrawObjects(node, [coreRow('id', '#5')])
expect(second[0]).toBe(first[0])
const third = badgeDrawObjects(node, [coreRow('id', '#6')])
expect(third[0]).not.toBe(first[0])
expect(third[0].text).toBe('#6')
})
})

Some files were not shown because too many files have changed in this diff Show More