Compare commits

...

136 Commits

Author SHA1 Message Date
DrJKL
42fc3fa340 refactor: migrate remaining first-party readers off deprecated slot mirrors
The deprecation getters fired for every user on the first rendered
frame, making extension telemetry meaningless. drawConnections now does
one pass over graph links instead of per-node per-input getter scans;
the subgraph resolvers take the input from link.resolve() they already
call; findInputByType and unpackSubgraph use the slotLinks helpers.
Adds the missing coverage: getter read/write behavior for both mirrors,
the slotLinks input trio, and a connect-draw-serialize cycle asserting
no deprecation warning fires.
2026-07-07 19:31:03 -07:00
DrJKL
2526d1cd58 fix: make slot shims and discriminators safe for ecosystem slot patterns
- _setConcreteSlots writes wrappers back into node.inputs/outputs so
  identity-based store lookups hold for duck-typed slots
- find-slot helpers take their direction from the call site instead of
  'link' in discrimination, which optional link made unsound; deletes
  slotIsConnected, widgetInputConnected, isINodeInputSlot, and
  IGenericLinkOrLinks
- addInput/addOutput drop legacy link/links keys from extra_info
  instead of letting Object.assign collide with the accessor
- deprecated mirror writes warn with the replacement APIs and are
  ignored, so legacy writers degrade gracefully instead of throwing
2026-07-07 19:21:37 -07:00
DrJKL
5d7032cb76 fix: disconnect input links from the store before firing onConnectionsChange
The all-links branch of disconnectOutput fired the target's callback
while the store still reported the input connected; the targeted branch
and disconnectInput already disconnected first. Collapses the two
mostly-duplicated branches into one per-link loop with the correct
ordering.
2026-07-07 19:18:30 -07:00
DrJKL
7697348c2f fix: realign input link slots from the effective configure data
Deletes fixLinkInputSlots: it traversed the workflow JSON passed to
onConfigure, but when definitions.subgraphs forces a structuredClone,
nodes configure from the clones and the original arrays never see the
in-place input reordering — so the #3348 slot repair read stale data.
LGraph.configure now realigns links right after duplicate removal using
nodeDataMap, the data nodes were actually configured from; subgraphs
realign themselves since Subgraph.configure is an LGraph.configure.
2026-07-07 17:46:36 -07:00
DrJKL
fc6819210c fix: snapshot dynamic widget input links once per rebuild
Deletes the module-scoped carriedLinks WeakMap and its mid-flight
re-capture, which read index-keyed store lookups while node.inputs was
desynced from registered slots and could re-point or remove an
unrelated input's link. A rebuild now captures one at-rest snapshot,
does all array surgery with plain splices, and re-keys every surviving
link in a single reconcile pass at the end.
2026-07-07 17:43:54 -07:00
DrJKL
34a71e80c2 fix: make store re-keying authoritative so slot permutations cannot drop links
First-wins placement is load-bearing only for load-time registration
(duplicate dedup needs losers to stay detached). Re-keying a registered
link already proved ownership by vacating its old key, so the move now
evicts any incumbent, which re-places itself when its own write arrives.
Deletes the silent-deregistration branch in applyEndpointPatch.
2026-07-07 17:41:46 -07:00
DrJKL
3772615fcb refactor!: delete the input.link runtime mirror
The linkStore is now the single source for slot connectivity in both
directions. The NodeInputSlot.link field and all runtime write sites are
gone; a read-only prototype getter derives the id from the store and
fires warnDeprecated as extension migration telemetry (no setter, so
writes throw). Wire format unchanged: serialization derives
inputs[].link from the store.

The mirror-carried association machinery is reworked rather than
papered over: fixLinkInputSlots consumes the serialized graph data
(where node.configure reorders inputs in place), dynamicWidgets' group
rebuilds carry slot-to-link association in a WeakMap refreshed from the
store, reorderSubgraphInputs captures outer links by pre-reorder index,
and link deduplication selects survivors from the store registration -
repairInputLinks is deleted because the derived view cannot be
inconsistent.

Extension migration: read via node.isInputConnected(slot) /
node.getInputLink(slot); mutate via node.connect() /
node.disconnectInput().
2026-07-07 16:40:51 -07:00
DrJKL
75ad306a80 refactor: litegraph internals read input.link from linkStore
The input query family (isInputConnected, getInputLink, getInputNode,
getInputData/DataType, getInputOrProperty), connect displacement,
disconnectInput, removeInput reindexing, bypass passes, canvas link
highlighting, subgraph boundary resolution, and ExecutableNodeDTO all
derive from the store via the slotLinks input helpers. Serialization
emits inputs[].link from the store; configure fires its callbacks from
the serialized argument. NodeInputSlot.isConnected and toJSON derive.
The slot-connectivity helpers move to module scope: private-field
methods break when nodes are wrapped in Vue reactive proxies.

Mirror still written; linkDeduplication keeps reading it (the store
cannot represent contested duplicate targets, which are its input).
2026-07-07 16:18:08 -07:00
DrJKL
78469817a7 refactor: migrate app-side input.link readers to store-backed queries
App code reads input connectivity through node.isInputConnected /
node.getInputLink or the new slotLinks input helpers (inputHasLink,
inputLinkId, inputLink) instead of the input.link mirror. Serialized
operators (litegraphUtil compression, migrateReroute, linkFixer) and the
mirror-carried association shuffles (dynamicWidgets autogrow,
fixLinkInputSlots, useNodeReplacement transplant writes) stay on the
mirror until it is deleted.
2026-07-07 10:44:16 -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
jaeone94
2e4c9c6fdc style: use neutral credits info button (#13461)
## Summary

Replaces the Additional credits tooltip trigger with the shared Button
component so the icon renders with the neutral muted treatment used by
the rest of the UI.

## Changes

- **What**: Uses the shared Button component for the Additional credits
info action while preserving the existing tooltip and aria label.
- **Dependencies**: None.

## Review Focus

Confirm this remains a visual-only change scoped to the Plan & Credits
credits tile.

## Screenshots (if applicable)

Before
<img width="397" height="368" alt="스크린샷 2026-07-06 오후 11 30 38"
src="https://github.com/user-attachments/assets/9cda1aee-4dc2-4ce1-b001-29d0e09fc07d"
/>

After
<img width="374" height="355" alt="스크린샷 2026-07-06 오후 11 31 00"
src="https://github.com/user-attachments/assets/64d0b726-6031-42d4-84d7-35405150698c"
/>


## Testing

- `pnpm format`
- `pnpm lint`
- pre-commit hook: stylelint, oxfmt, oxlint, eslint, typecheck
- `pnpm test:unit
src/platform/cloud/subscription/components/CreditsTile.test.ts`
2026-07-06 14:42:49 +00:00
Mobeen Abdullah
7d2858e74b feat(website): add Supported Models link to nav and footer (#13432)
## Summary

Surface the supported-models catalog (`/p/supported-models`) from the
home page by adding a "Supported Models" link to the Products dropdown
and the footer.

## Changes

- **What**: Add a `nav.supportedModels` i18n entry (en `Supported
Models`, zh-CN `支持的模型`) and link it to the existing `routes.models`
constant in two places: the Products mega-menu Features column (between
Launches and Docs) and the footer Products column (after Comfy MCP).
Locale handling comes from `getRoutes`, so zh-CN resolves to
`/zh-CN/p/supported-models` automatically.

## Review Focus

- Placement is intentionally in **both** the nav and the footer (per
FE-1190). Fine to drop either, happy to adjust after review.
- Reuses the existing nav/footer link pattern, no new components or
styles, and no `new` badge (the page is not newly launched).
- Consumes `routes.models`, which was already defined but previously
unused.

FE-1190

## Screenshots

**Nav — Products dropdown, Features column**

_Desktop:_

_Mobile:_

**Footer — Products column**

_Desktop:_

_Mobile:_

Preview: https://comfy-website-preview-pr-13432.vercel.app
2026-07-04 09:33:26 +05:00
Robin Huang
b9a0ac0fed fix(manager): drop embed=true so the Cloud survey renders its dark theme (#13438)
Remove `embed=true`, so PostHog applies the survey's own dark appearance
and it renders correctly.

Verified live on testcloud that removing the flag fixes the styling;
`distinct_id` user linkage is unaffected.
2026-07-04 01:56:29 +00:00
AustinMroz
b61e54db3b Allow forcing icon display as mask or image (#13414)
- Adds support for forcing an icon to display as a mask or image with
`icon-mask` and `icon-image`.
- Updated the logic so that svg of a solid color (like the claude logo)
display as an image by default
- Update many svg to consistently use `currentColor` so that they still
function as masks by default
2026-07-04 00:04:56 +00:00
jaeone94
b51ea29074 test: clean up TemplateHelper route mocks (#13019)
## Summary

Follow-up draft PR for the CodeRabbit issues created from the #12999
review. This keeps the original stabilization PR merged as-is and moves
the non-functional TemplateHelper cleanup into its own small branch.

## Changes

- Extracted TemplateHelper route patterns into named module-scope
constants.
- Normalized the TemplateHelper route patterns to anchored regexes with
optional query-string handling.
- Extracted `mockCustomTemplates()` from `mockIndex()` and made `mock()`
register custom templates, core index, and thumbnails together.
- Added a private `registerRoute()` helper so every mocked route is
registered for teardown consistently.
- Simplified the fixed empty custom-template response to `body: '{}'`.
- Updated the cloud template filtering spec to use `templateApi.mock()`
instead of manually combining thumbnail and index mocks.

## Issues

- Closes #13014
- Closes #13016
- Closes #13017
- Closes #13018
- Related #13015: this PR normalizes the TemplateHelper route patterns
only. The broader fixture-wide route pattern convention cleanup remains
intentionally separate.

## Validation

- `pnpm exec oxfmt --check
browser_tests/fixtures/helpers/TemplateHelper.ts
browser_tests/tests/templateFilteringCount.spec.ts`
- `pnpm exec eslint browser_tests/fixtures/helpers/TemplateHelper.ts
browser_tests/tests/templateFilteringCount.spec.ts`
- `pnpm typecheck:browser`
- Pre-commit hook also ran `oxfmt`, `oxlint`, `eslint`, `pnpm
typecheck`, and `pnpm typecheck:browser` successfully.

Note: I attempted the targeted cloud Playwright spec locally with
`PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm exec
playwright test browser_tests/tests/templateFilteringCount.spec.ts
--project=cloud`, but the local 5173 app was not running with the cloud
distribution configuration, so the distribution-filter assertions failed
in the expected local/cloud mismatch way. This should be verified by
CI's cloud project.
2026-07-03 23:55:52 +00:00
Robin Huang
d85ce2bf9e feat: add custom nodes waitlist survey to Manager button on Cloud (#13135)
## Summary

On Comfy Cloud, show the Manager button and open a hosted survey in the
manager modal (in place of the local node manager) so we can gauge
demand for custom nodes on Cloud.

## Changes

- **What**: `TopMenuSection` shows the Manager button when `isCloud`;
clicking it opens `ManagerSurveyDialog`, which embeds a PostHog hosted
survey via iframe. The survey URL comes per-environment from cloud
config (`manager_survey_url`), with the logged-in user's `distinct_id`
appended so responses link to the user. Includes loading/error states
and PostHog's `posthog:survey:height` iframe auto-resize.
- **Dependencies**: none

## Review Focus

- Survey URL is sourced from `remoteConfig.manager_survey_url` (must be
set per environment in cloud config); falls back to an error state when
unset or malformed.
- iframe embedding requires the PostHog survey to be `external_survey`
type with embedding enabled.
2026-07-03 18:12:34 +00:00
Benjamin Lu
fa87c46f90 chore: remove PreToolUse pnpm-enforcement hooks (#13422)
## Summary

Removes the Claude Code PreToolUse hooks added in #11201. Their `if`
patterns blocked any bash command the static pattern parser could not
fully resolve — loop variables, `$(...)`/backtick substitution,
heredocs, `${...}` expansions — with a misleading error naming a random
unrelated tool. Transcript analysis across a month of sessions found 37
hook firings: 1 true positive, 36 false positives (~97%), including
blocking `pnpm typecheck ... | tail` itself.

## Changes

- **What**: Delete `.claude/settings.json` (it contained only the hook
config) and the script-based replacement from earlier revisions of this
PR.
- Earlier revisions replaced the hooks with a stdin-inspecting matcher
script, but the hooks' original rationale — protecting Nx task
orchestration back when `test:unit` was `nx run test` — disappeared when
Nx was removed in #12355 and the pnpm scripts became direct tool
invocations. The remaining value (nudging agents toward pnpm scripts)
does not justify maintaining a bash-parsing matcher with its own edge
cases.

## Review Focus

- Agents can now run `npx tsc` / `npx vitest` etc. without being
redirected; the pnpm-script convention remains documented in AGENTS.md,
which is what agents follow in practice.
2026-07-03 06:57:05 +00:00
Benjamin Lu
941f151520 fix: avoid duplicate unit coverage execution (#13423)
## Summary

Stop running the full Vitest suite twice in unit CI. The critical
coverage gate is now a glob-keyed `coverage.thresholds` entry enforced
during the single `pnpm test:coverage` run, instead of a second
`COVERAGE_CRITICAL=true vitest run --coverage` pass.

## Changes

- **What**: All critical directories form one brace-expanded glob key in
`coverage.thresholds`; Vitest aggregates the matching files into a
single bucket and checks the existing thresholds (69/60/67/70) against
it during the normal coverage run. Untested files matching
`coverage.include` are counted at 0%, preserving the previous gate's
semantics.
- **What**: Narrows the litegraph coverage exclusion from a blanket
`src/lib/litegraph/**` to the non-critical subfolders, so the critical
litegraph folders (`node`, `subgraph`, `utils`) are present in the
coverage report the thresholds read.
- **What**: Removes the `test:coverage:critical` script, the
`COVERAGE_CRITICAL` env branch in `vite.config.mts`, and the separate CI
gate step.

## Notes

- The normal coverage report now includes the critical litegraph
folders, so the Codecov `unit` flag and the coverage Slack baseline will
show a one-time shift.
- Filtered local runs (`pnpm test:coverage <file>`) fail the gate since
most critical files are uncovered; a full `pnpm test:coverage`
reproduces CI exactly.

Validation:
- `pnpm typecheck`, `pnpm exec eslint vite.config.mts`, `pnpm
format:check`, `pnpm knip` (pre-push)
- Smoke: `pnpm vitest run --coverage src/utils/colorUtil.test.ts` —
tests pass, then the gate fails all four metrics against the critical
bucket and exits 1, confirming enforcement happens inside the single run
- Full-suite gate numbers should be confirmed in CI
2026-07-03 05:43:05 +00:00
Benjamin Lu
df5f5b3367 feat: identify auth users to Syft (#13311)
## Summary

Identifies Cloud auth users to Syft via the required `identify(email, {
source })` handoff so Syft enrichment reliably attaches to signup/login
users instead of relying only on GTM page-load capture.

## Changes

- **What**: Adds a cloud-only Syft telemetry provider that reads
`syftdata_source_id` from `remoteConfig`, lazy-loads the Syft SDK
(reusing an already-loaded GTM Syft client when present), and calls
`identify` with `source: 'signup'` or `source: 'login'` on auth and on
session restore. `trackUserLoggedIn()` dedupes against the email already
handled by `trackAuth()` so a fresh login is not identified twice.
- **Dependencies**: None.

## Review Focus

- Preserves the FE-945 startup-blocker fix: the constructor reads only
the `remoteConfig` ref (a plain reactive ref, not Pinia) and never
touches current-user state; the user-email lookup happens only in
`trackUserLoggedIn()`, after app/auth setup. The source id is present at
construction because `main.ts` awaits the anonymous
`refreshRemoteConfig` before `initTelemetry`, and a later authenticated
refresh is picked up reactively on the next `ensureSyftClient()` call.
- SDK loader is idempotent with the current GTM Syft tag during rollout
(one script per `SYFT_SRC`). On load failure it clears its own stub —
guarded by an identity check so it never evicts a real client another
loader installed — letting a subsequent call retry. Long-term cleanup is
to keep one loader path per surface.
- Acceptance should include staging Network verification for
`https://e2.sy-d.io/events` payloads containing an `identify` event for
Google, GitHub, and email auth.

Linear: GTM-168
2026-07-03 05:10:52 +00:00
imick-io
156f2f59b7 feat(website): swap nav featured card to Comfy MCP (#13388)
## Summary

Repurpose the Products dropdown featured card to promote Comfy MCP.

## Changes

- **What**: Update the nav featured card title ("NEW: COMFY MCP"), alt
text, image asset (`mcp-card.webp`), and CTA ("GET STARTED") in
`mainNavigation.ts`; route the CTA to the localized `/mcp` page via
`routes.mcp`. All copy is i18n'd (en + zh-CN) in `translations.ts`,
adding a reusable `cta.getStarted` key.

## Review Focus

- CTA uses a new reusable `cta.getStarted` key rather than the
section-scoped `mcp.setup.label`, and routes to the internal
`routes.mcp` so non-en locales resolve to `/{locale}/mcp`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:01:55 +00:00
nav-tej
d855466fdf fix(website): cap contact intro text width and space it from the form (#13420)
*PR Created by the Glary-Bot Agent*

---

## Summary

On `comfy.org/contact` the intro copy ("Create powerful workflows, scale
without limits." + description) ran right up against the HubSpot form
fields on desktop. The two `lg:w-1/2` columns had no gap between them
and the left column had no max-width, so long description text extended
almost to the form's edge.

- Add `lg:gap-16` between the two columns in `FormSection.vue`, matching
the pattern already used by `common/ContentSection.vue` and
`legal/LegalContentSection.vue`.
- Wrap the intro text block (badge + heading + description) in an
`lg:max-w-xl` container so the copy no longer stretches into the gap.
The illustration below keeps its full-column bleed via the existing
`lg:-ml-20`.
- Mobile (`<lg`) layout is unchanged — all new classes are
`lg:`-prefixed.

## Verification

Screenshots taken via Playwright against the local `apps/website` dev
server:

- Desktop (1512×900) — intro text now caps at a comfortable line length
with a clear gap to the form.
- 1024px — still works at the `lg:` breakpoint.
- 375px mobile — visually identical to before.

Also ran `pnpm format` and `pnpm --filter @comfyorg/website typecheck`
(0 errors). Three pre-existing
`better-tailwindcss/enforce-consistent-class-order` lint warnings on
this file exist on `main` and were left untouched.

- Fixes contact-page layout complaint from #website-and-docs (July 2)

## Screenshots

![Contact page after fix at 1512px — intro text capped with clear gap to
form](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049233475-b5932d36-9087-4689-a7ea-925bad2f09ff.png)

![Contact page after fix at 1024px
viewport](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049234208-8ef2cdac-b929-4c4d-b60a-e794f989fd76.png)

![Contact page after fix at 375px mobile viewport — layout
unchanged](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049234909-c502d978-2586-4ce7-b54a-d96fc759d306.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-07-03 03:51:31 +00:00
AustinMroz
9d5719871a Compact vue nodes (#12886)
Updates vue nodes to be compact. 

This PR does modify the sizing of the asset dropdown (as used on nodes
like "Load Image"). There are outstanding concerns about the visibility
of the upload button and ongoing work to address this.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/5c866d6f-d83e-40e1-9d87-17b990d94e04"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/2a809e90-13aa-4f95-8b73-3f20b02fd9a1"
/>|

Subsumes #12678

---------

Co-authored-by: Alex <alex@Alexs-MacBook-Pro.local>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-03 02:31:41 +00:00
ShihChi Huang
7610a61250 test: cover queue display formatting (#13089)
## Summary

Add direct tests for queue job display formatting.

Base: `main`

## Changes

- Covers state icons, pending/initializing labels, running progress,
completed local/cloud output, fallback completed titles, and failed
display.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:unit src/utils/queueDisplay.test.ts --run` | no direct
queue display test file |  13 passed |

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/queueDisplay.ts` 22.72% -> 79.54% (+56.82%); overall branches
52.95% -> 53.03% (+0.08%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only change; no runtime or production code modified.
> 
> **Overview**
> Adds **`src/utils/queueDisplay.test.ts`**, a Vitest suite that
exercises **`iconForJobState`** and **`buildJobDisplay`** from
`queueDisplay.ts` without touching UI or production logic.
> 
> Tests use small **`createJob` / `createTask` / `createCtx`** helpers
with a stub **`t`** and clock formatter so expectations assert i18n keys
and formatted values. Coverage includes pending “added to queue” hint,
queued/initializing labels, active vs inactive running progress,
completed local preview vs cloud duration, completed title fallback, and
failed rows with **`showClear`** behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6260c101e5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:39:24 +00:00
ShihChi Huang
47c8b09ebf test: 2/x cover fuse search ranking (#13087)
## Summary

Add direct tests for `fuseUtil` search ranking and filter behavior.


## Changes

- Covers ranking tiers, deprecated penalties, post-processing, empty
queries, auxiliary score comparison, and filter wildcard/comma matching.

## Test Results

- `pnpm test:unit src/utils/fuseUtil.test.ts`: 7 passed.
- `pnpm typecheck`: passed.
- `pnpm test:coverage`: 876 test files passed; 11,759 passed / 8
skipped.

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/fuseUtil.ts` 81.48% -> 92.59% (+11.11%); overall branches
52.93% -> 52.95% (+0.02%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a
summary for commit 8bf748d1a4. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-07-02 22:30:50 +00:00
ShihChi Huang
65b4c53bcb ci: skip website report deploy for fork PRs (#13344)
## Summary

Skip the website e2e report/deploy step for fork PRs, which lack the
deploy secrets and otherwise fail the job.

## Changes

- **What**: Guard the report/deploy step's `if:` in
`ci-website-e2e.yaml` so it runs only when the event is not a fork pull
request.
- **Breaking**: none. CI-config only.

## Review Focus

CI-config only — no test or coverage change. Confirms fork PRs no longer
fail on the deploy step.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> CI workflow condition only; no application or test logic changes.
> 
> **Overview**
> **Website E2E CI** no longer runs the **Deploy report to Cloudflare**
step on pull requests from forks.
> 
> The step’s `if:` still requires `always()` and `!cancelled()`, and now
also requires either a non–pull-request event or a PR whose head repo is
**not** a fork. Playwright tests and artifact upload are unchanged; only
the wrangler deploy (which needs `CLOUDFLARE_*` secrets) is skipped for
fork PRs so those runs don’t fail when secrets aren’t available.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
02a4ab0769. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:30:03 +00:00
ShihChi Huang
15b31d69ea ci: skip secret-backed CI deploys for fork PRs (#13291)
## Summary

Skip secret-backed CI deploy and dispatch work for fork PRs so missing
repo secrets do not fail otherwise valid checks.

## Changes

- **What**: Guard Website E2E report deploy, Vercel website preview
deploy, cloud build dispatch, cloud cleanup dispatch, and Storybook
Chromatic deploy so PR paths only run for same-repo PRs.
- **Dependencies**: None

## Why

Fork `pull_request` runs do not receive repository secrets. Several CI
jobs already separated normal validation from privileged follow-up work,
but some deploy or dispatch steps could still run on fork PRs and fail
only because their secret-backed integration token was empty.

The existing Website E2E fork guard only protected the PR comment job.
It did not protect the earlier Cloudflare report deploy step inside
`website-e2e`, which uses `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`.

The same failure mode existed in these CI jobs:

- `ci-vercel-website-preview.yaml`: preview deploy uses Vercel and
website API secrets.
- `cloud-dispatch-build.yaml`: preview dispatch uses
`CLOUD_DISPATCH_TOKEN` to call `Comfy-Org/cloud`.
- `cloud-dispatch-cleanup.yaml`: preview cleanup dispatch uses
`CLOUD_DISPATCH_TOKEN`.
- `ci-tests-storybook.yaml`: Chromatic deploy uses
`CHROMATIC_PROJECT_TOKEN`.

`ci-website-build.yaml` was left unchanged. Its Ashby and Cloud nodes
integrations intentionally fall back to committed snapshots when secrets
are missing for preview/local builds, so it is not the same class of
fork-secret failure.

## Review Focus

Confirm fork PRs still run the unprivileged validation/build paths,
while same-repo PRs and non-PR events keep the existing deploy or
dispatch behavior.

## Validation PRs

Both validation PRs compare against `main`.

- Fork PR from `shihchi`:
[#13309](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13309)
- Same-repo PR from `origin`:
[#13310](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13310)

| Workflow | Guarded job or step | Fork #13309 | Same-repo #13310 |
| --- | --- | --- | --- |
| CI: Website E2E | `Upload test report` | success  | success  |
| CI: Website E2E | `Deploy report to Cloudflare` | skipped  | success
 |
| CI: Vercel Website Preview | `deploy-preview` | skipped  | success 
|
| Cloud Frontend Build Dispatch | `dispatch` | skipped  | success  |
| CI: Tests Storybook | `chromatic-deployment` | skipped  | success  |

Expected result: fork PRs still keep the useful validation artifact
path, but skip secret-backed deploy and dispatch work. Same-repo PRs
keep the privileged behavior.

## Screenshots (if applicable)

N/A, CI-only.

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Workflow `if` condition changes only; no application code. Same-repo
PR behavior is unchanged when secrets are available.
> 
> **Overview**
> Adds **`github.event.pull_request.head.repo.fork == false`** guards so
fork PRs no longer run steps that need repo secrets or trigger external
deploys.
> 
> **Website E2E** — the Cloudflare Playwright report deploy step now
runs only on non-PR events or same-repo PRs, so fork runs can still pass
tests and upload artifacts without failing on missing `CLOUDFLARE_*`
secrets.
> 
> **Vercel website preview** — the preview deploy job is skipped
entirely for fork PRs (Vercel tokens).
> 
> **Storybook Chromatic** — Chromatic deployment on `version-bump-*` PRs
is limited to non-fork PRs (`CHROMATIC_PROJECT_TOKEN`).
> 
> **Cloud dispatch** — build and cleanup dispatches to the cloud repo
for preview labels no longer run for fork PRs, aligning with the
existing fork-guard comment in those workflows.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
027aabc9e3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:29:47 +00:00
Benjamin Lu
471236e08d feat: track subscription cancellation intent and resubscribe clicks (#13368)
## Summary

Instruments the churn funnel: cancellation intent, attempt, abandonment,
and request failure, plus resubscribe clicks — all client-observed from
existing request/response flows, no watchers or polling added. Covers
both billing paths: the mainline (`/customers/*` + Stripe portal) path
via the "Manage subscription" click, and the workspace path via its
in-app cancel dialog.

## Changes

- **What**:
- New events: `app:subscription_cancel_flow_opened` / `_confirmed` /
`_abandoned` / `_failed` and `app:resubscribe_button_clicked`, via
`trackSubscriptionCancellation(stage, metadata)` and
`trackResubscribeClicked` (registry, PostHog, host sink)
  - All cancellation events carry a `source` discriminator:
- `manage_subscription_button` — the mainline path. Legacy users can
only cancel inside the Stripe billing portal, and in-app UI already
covers plan changes, so this click is the closest observable
cancel-intent signal for ~all production users. Only `flow_opened` fires
here (everything past the click happens in Stripe's UI). Probable, not
certain, intent — the portal also serves card updates/invoices.
- `cancel_plan_menu` — the workspace in-app dialog (allowlist-gated
pilot): `flow_opened` on mount, `confirmed` before the API call (failed
attempts still register), `failed` with the error message, `abandoned`
on "Keep subscription"/close. Successful cancels close via a different
path and never emit `abandoned`.
- Metadata carries `current_tier`, billing `cycle`, and (dialog path)
the `end_date` shown to the user
- Resubscribe clicks tracked at both call sites with `source`:
`pricing_dialog` (`useSubscriptionCheckout`, also carrying the dialog's
`payment_intent_source` from #13363) and `settings_billing_panel`
(`useResubscribe`)
- Not instrumented on purpose: the workspace "Manage billing" button and
the "Invoice history" footer link (portal opens without cancel
connotation)

## Review Focus

- Deliberately **no** client-side "cancel succeeded" event: outcome
truth is server-side. Mainline already has it
(`billing:subscription_deleted` from the Stripe webhook in comfy-api);
the workspace path needs a `subscription_cancelled` billing event type
(separate cloud-repo change). The legacy
`useSubscriptionCancellationWatcher` poller emits an undercounted
`app:monthly_subscription_cancelled`; analysis should prefer the server
event.
- `confirmed` fires before the request; growth can join
`flow_opened`/`confirmed` → server-side cancelled events by user +
timestamp.
2026-07-02 14:51:12 -07:00
Mobeen Abdullah
4cc0402325 revert(website): remove Creative Campus customer stories (#13370) (#13407)
## Summary

Reverts #13370 (the five Creative Campus customer stories) from `main`.
These are education-tied stories, and the "Education Program is live"
CTA links to the education page, which is not live yet, so they should
not be public before the education launch.

This is a clean `git revert` of the squash commit `49a90d4e2` (no
history rewrite, no force-push). No work is lost: the story branch
(`feat/website-customer-stories-education`) is intact, and the stories
will relaunch together with pricing and the education page via #13406.

## Changes

- **What**: Reverts the 5 new story MDX files, the new article block
components, and the related changes to `CustomerArticle.astro`,
`global.css`, `Figure`/`Quote`/`Contributors`, the content test, and the
e2e spec. The existing five stories and the customers pages are
unaffected.
- **Breaking**: none.

## Review Focus

- Pure inverse of #13370; the diff is `-858/+11` mirroring the original
merge.
- Files touched by #13370 are disjoint from the education-page work in
#13406, so this does not conflict with that branch.

## Verification

- Build: 497 pages (down 5 en story pages). Unit: 156/156. Typecheck: 0
errors. format:check and knip clean.

## Next steps

- Stories move into the education bundle (#13406) via a separate PR.
- When the education page and its auth (FE-1174) are ready, pricing +
customer stories + education launch together.
2026-07-03 01:49:47 +05:00
Wei Hai
a2adfe5124 fix(ci): drop unsupported 'range' genhtml ignore-errors category (#13396)
## Summary
- `CI: E2E Coverage`'s `Generate HTML coverage report` step fails on
every run with `genhtml: ERROR: unknown argument for --ignore-errors:
'range'`
- The runner's `apt-get install lcov` resolves to lcov 2.0-4ubuntu2
(Ubuntu 24.04/noble), but the `range` ignore-errors category was only
added in lcov 2.1
- lcov 2.0 already reports the out-of-range-line condition under the
`source` category, which is already in the ignore list, so `range` was
both unsupported and redundant on this runner

## Test plan
- [x] Confirmed lcov 2.0-4ubuntu2 is what `apt-get install lcov`
resolves to on `ubuntu-latest`
- [x] Confirmed via lcov's `lcovutil.pm` source that `range`
(`$ERROR_RANGE`) is only registered as of v2.1, and in v2.0 the
equivalent out-of-range case falls under `$ERROR_SOURCE`
- [ ] CI: E2E Coverage run on this branch's merge should pass the
"Generate HTML coverage report" step
2026-07-02 20:08:47 +00:00
Mobeen Abdullah
49a90d4e2e feat(website): add five Creative Campus customer stories (#13370)
## Summary

Add the five new Comfy Education Initiative (Creative Campus) customer
stories to `/customers`, each with its own detail page, reusing the
existing Astro content-collection pattern. Brings the listing to ten
stories. Linear: FE-1161.

## Changes

- **What**: Five new English MDX stories (Xindi Zhang, Ina Conradi,
Golan Levin, Kathy Smith, and the UAL CCI partnership) added to the
customers collection, ordered after the existing five. Adds a small set
of reusable article blocks these stories need: `Embed` (Vimeo), `Video`
(wraps the existing `VideoPlayer`), `Download` (workflow JSON),
`AuthorBio`, `EducationCta`, `AtAGlance`, a styled inline `Link`, and
`Heading4`. `Quote`'s `name` is now optional for unattributed
pull-quotes; `Figure` gained an optional rich-caption slot (for captions
that contain links); `AuthorBio` supports a single-author bio via slot.
- **Breaking**: none. All additions are backward compatible; the
existing five stories and their pages are untouched.
- **Dependencies**: none.

## Review Focus

- The logic to review is small and isolated: the new block components in
`components/customers/content/` and their registration in
`CustomerArticle.astro`. The rest of the diff is MDX content.
- **Story copy is transcribed verbatim from the source docs**;
punctuation (em/en dashes, curly quotes) is preserved as written and is
intentional, not a formatting slip.
- **Downloads (cross-origin):** the workflow JSON files are on
media.comfy.org, so the HTML `download` attribute is ignored by
browsers. The real download is forced server-side with
`Content-Disposition: attachment` on the storage objects. Xindi's two
workflow files are served from a cache-fresh `.../workflows/` path (with
an explicit `filename=`) so the CDN serves the attachment header
immediately.
- **Embed hardening:** the Vimeo `Embed` iframe carries
`referrerpolicy="strict-origin-when-cross-origin"` and a scoped
`sandbox` (`allow-scripts allow-same-origin allow-presentation
allow-popups`); the player was verified to still load and play.
- All media (card covers, inline images, one video with a poster frame,
workflow JSON/PNG downloads) is hosted on media.comfy.org. No local
assets are committed. Golan's workflow files are re-hosted there; his
lesson-plan and demo-project links intentionally stay on GitHub/p5.js as
view-only.
- English-first: Chinese versions will be added later through a separate
translation service. The listing and detail pages already handle a
locale that only has English entries, so no page-code changes were
needed.
- Tags: "Creative Campus Showcase" for the four teaching stories, and
"Creative Campus Partnership" for the UAL announcement.

## Verification

- Unit `176/176`, typecheck (astro check) `0 errors`, build `502 pages`,
`format:check`, `knip`, and `eslint` all pass.
- e2e customer specs `6/6` pass (includes a new test asserting the
Creative Campus education blocks render).
- Visual pass on all ten stories at desktop (1440) and mobile (390): no
horizontal overflow, the Vimeo player plays, and all downloads resolve
to media.comfy.org.

## Screenshots (if applicable)

Easiest way to review is the Vercel preview:
https://comfy-website-preview-pr-13370.vercel.app/customers then open
the five new stories. Verified on desktop (1440) and mobile (390).
2026-07-03 00:34:20 +05:00
Hunter
d6c582c399 feat(billing): gate consolidated billing behind consolidated_billing_enabled flag (#13359)
## Summary

Shields personal-workspace billing code paths behind the new
`consolidated_billing_enabled` feature flag so they fall back to the
**legacy** billing flow while the flag is `false`. Team workspaces are
unaffected and continue to use the workspace-scoped billing flow.

## Changes

- Add `consolidatedBillingEnabled` to `useFeatureFlags` (reads the
`consolidated_billing_enabled` server flag / remote config, defaults to
`false`) and to the `RemoteConfig` type.
- New `useBillingRouting` composable — a single source of truth for
whether the active workspace uses the workspace vs. legacy billing flow:
  - team workspaces disabled → legacy
  - personal workspace + consolidated billing off/missing → legacy
  - personal workspace + consolidated billing on → workspace
  - team workspace → workspace
  - workspace not loaded yet → legacy
- Route `useBillingContext` and the affected UI sites
(`SubscriptionPanel`, `useSubscriptionDialog`, `UsageLogsTable`,
`TopUpCreditsDialogContentLegacy`) through `useBillingRouting` instead
of keying on `teamWorkspacesEnabled` directly.
- Update the storybook `useFeatureFlags` mock to stay in sync.

## Testing

- `pnpm test:unit` for `useBillingRouting`, `useBillingContext`,
`useSubscriptionDialog`, and `UsageLogsTable` (new + updated coverage
for the routing matrix). Remaining quality gates (`typecheck`, `lint`)
are being verified in CI.

## Related

Requires the backend PR that adds the `consolidated_billing_enabled`
flag to `/api/features`.

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-07-02 18:34:39 +00:00
imick-io
a6db1ab3d6 fix(website): restore node-link.svg intrinsic sizing (#13384)
## Summary

Restore the original `node-link.svg` asset, which PR #13095 accidentally
overwrote with a stretch-to-fill Figma export, breaking the node
connector across the marketing site.

## Changes

- **What**: Revert `apps/website/public/icons/node-link.svg` to its
intrinsic **20×32** form (`fill="#F2FF59"`). PR #13283 had replaced it
with a raw Figma export (`preserveAspectRatio="none"`, `width="100%"
height="100%"`, `fill="var(--fill-0, …)"`). Every consumer loads it as a
bare `<img src>` and relies on the intrinsic size plus
`scale-*`/`rotate` classes — with no intrinsic dimensions the connector
expanded to fill its container and distorted.

## Review Focus

- The overwrite originated in the first commit of #13283's stack and
rode through the squash merge; nothing in that PR actually referenced
this file (the MCP page uses the separate `NodeUnionIcon.vue`), so
restoring the shared asset fixes all consumers (`BuildWhatSection`,
`ProductShowcaseSection`, `OurValuesSection`, `GalleryDetailModal`)
without touching the MCP page.
- `apps/website/dist/icons/node-link.svg` is stale build output and
regenerates on the next `pnpm build`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-02 13:07:00 +00:00
439 changed files with 13195 additions and 5699 deletions

View File

@@ -1,86 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
]
}
}

View File

@@ -134,6 +134,27 @@ jobs:
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Syft telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Syft references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)syft' \
-e '(?i)sy-d\.io' \
dist; then
echo '❌ ERROR: Syft references found in dist assets!'
echo 'Syft must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Syft references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail

View File

@@ -121,7 +121,7 @@ jobs:
--title "ComfyUI E2E Coverage" \
--no-function-coverage \
--precision 1 \
--ignore-errors source,unmapped,range \
--ignore-errors source,unmapped \
--synthesize-missing
- name: Upload HTML report artifact

View File

@@ -95,6 +95,7 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -55,6 +55,3 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Enforce critical coverage gate
run: pnpm test:coverage:critical

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -67,7 +67,15 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -32,12 +32,13 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,6 +21,7 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
</svg>

Before

Width:  |  Height:  |  Size: 380 B

After

Width:  |  Height:  |  Size: 279 B

View File

@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">

View File

@@ -38,7 +38,8 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{ label: t('nav.comfyApi', locale), href: routes.api },
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
{ label: t('nav.mcpServer', locale), href: routes.mcp }
{ label: t('nav.mcpServer', locale), href: routes.mcp },
{ label: t('nav.supportedModels', locale), href: routes.models }
]
},
{

View File

@@ -33,36 +33,41 @@ useHeroAnimation({
</script>
<template>
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
<section
ref="sectionRef"
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
>
<!-- Left column: intro + image -->
<div class="lg:w-1/2">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<div class="lg:max-w-xl">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<h1
ref="headingRef"
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<div ref="descRef">
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('description'), locale) }}
</p>
<div ref="descRef">
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('description'), locale) }}
</p>
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
</div>
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">

View File

@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
label: t('nav.products', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
cta: {
label: t('cta.tryWorkflow', locale),
label: t('cta.getStarted', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
href: routes.mcp
}
},
columns: [
@@ -82,6 +82,7 @@ export function getMainNavigation(locale: Locale): NavItem[] {
href: routes.launches,
badge: 'new'
},
{ label: t('nav.supportedModels', locale), href: routes.models },
{
label: t('nav.docs', locale),
href: externalLinks.docs,

View File

@@ -26,6 +26,10 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.getStarted': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
@@ -2183,6 +2187,7 @@ const translations = {
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.supportedModels': { en: 'Supported Models', 'zh-CN': '支持的模型' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },
@@ -2196,16 +2201,16 @@ const translations = {
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
'nav.featuredProductsTitle': {
en: 'New Release: Seedance 2.0',
'zh-CN': '全新发布:Seedance 2.0'
en: 'NEW: COMFY MCP',
'zh-CN': '全新发布:Comfy MCP'
},
'nav.featuredProductsAlt': {
en: 'Seedance 2.0 release feature image',
'zh-CN': 'Seedance 2.0 发布精选图片'
en: 'Comfy MCP feature image',
'zh-CN': 'Comfy MCP 精选图片'
},
'nav.featuredProductsCtaAria': {
en: 'Try the Seedance 2.0 workflow',
'zh-CN': '试用 Seedance 2.0 工作流'
en: 'Get started with Comfy MCP',
'zh-CN': '开始使用 Comfy MCP'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',

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

@@ -537,7 +537,6 @@ export const comfyPageFixture = base.extend<{
'Comfy.TutorialCompleted': true,
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true,

View File

@@ -6,6 +6,10 @@ import type {
} from '@/platform/workflow/templates/types/template'
import { mockTemplateIndex } from '@e2e/fixtures/data/templateFixtures'
const ROUTE_PATTERN_WORKFLOW_TEMPLATES = /\/api\/workflow_templates(?:\?.*)?$/
const ROUTE_PATTERN_TEMPLATE_INDEX = /\/templates\/index\.json(?:\?.*)?$/
const ROUTE_PATTERN_TEMPLATE_THUMBNAILS = /\/templates\/.*\.webp(?:\?.*)?$/
interface TemplateConfig {
readonly templates: readonly TemplateInfo[]
readonly index: readonly WorkflowTemplates[] | null
@@ -41,10 +45,6 @@ export function withTemplates(templates: TemplateInfo[]): TemplateOperator {
export class TemplateHelper {
private templates: TemplateInfo[]
private index: WorkflowTemplates[] | null
private routeHandlers: Array<{
pattern: string
handler: (route: Route) => Promise<void>
}> = []
constructor(
private readonly page: Page,
@@ -64,29 +64,30 @@ export class TemplateHelper {
}
async mock(): Promise<void> {
await this.mockCustomTemplates()
await this.mockIndex()
await this.mockThumbnails()
}
async mockIndex(): Promise<void> {
async mockCustomTemplates(): Promise<void> {
const customTemplatesHandler = async (route: Route) => {
const customTemplates: Record<string, string[]> = {}
await route.fulfill({
status: 200,
body: JSON.stringify(customTemplates),
body: '{}',
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
}
const customTemplatesPattern = '**/api/workflow_templates'
this.routeHandlers.push({
pattern: customTemplatesPattern,
handler: customTemplatesHandler
})
await this.page.route(customTemplatesPattern, customTemplatesHandler)
await this.page.route(
ROUTE_PATTERN_WORKFLOW_TEMPLATES,
customTemplatesHandler
)
}
async mockIndex(): Promise<void> {
const indexHandler = async (route: Route) => {
const payload = this.index ?? mockTemplateIndex(this.templates)
await route.fulfill({
@@ -98,9 +99,8 @@ export class TemplateHelper {
}
})
}
const indexPattern = '**/templates/index.json'
this.routeHandlers.push({ pattern: indexPattern, handler: indexHandler })
await this.page.route(indexPattern, indexHandler)
await this.page.route(ROUTE_PATTERN_TEMPLATE_INDEX, indexHandler)
}
async mockThumbnails(): Promise<void> {
@@ -114,12 +114,8 @@ export class TemplateHelper {
}
})
}
const thumbnailPattern = '**/templates/**.webp'
this.routeHandlers.push({
pattern: thumbnailPattern,
handler: thumbnailHandler
})
await this.page.route(thumbnailPattern, thumbnailHandler)
await this.page.route(ROUTE_PATTERN_TEMPLATE_THUMBNAILS, thumbnailHandler)
}
getTemplates(): TemplateInfo[] {
@@ -129,15 +125,6 @@ export class TemplateHelper {
get templateCount(): number {
return this.templates.length
}
async clearMocks(): Promise<void> {
for (const { pattern, handler } of this.routeHandlers) {
await this.page.unroute(pattern, handler)
}
this.routeHandlers = []
this.templates = []
this.index = null
}
}
export function createTemplateHelper(

View File

@@ -7,10 +7,6 @@ export const templateApiFixture = base.extend<{
templateApi: TemplateHelper
}>({
templateApi: async ({ page }, use) => {
const templateApi = createTemplateHelper(page)
await use(templateApi)
await templateApi.clearMocks()
await use(createTemplateHelper(page))
}
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -28,7 +28,12 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
// consolidated_billing_enabled routes personal workspaces to the unified
// pricing table asserted here; without it they fall back to the legacy table.
const BOOT_FEATURES = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
} satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the
// GraphCanvas onMounted chain before the deep-link loader.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -46,6 +46,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 21 KiB

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: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -691,7 +691,8 @@ test(
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
await stepsSlot.hover()
const { width, height } = (await stepsSlot.boundingBox())!
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -17,7 +17,7 @@ test.describe(
'Template distribution filtering count',
{ tag: '@cloud' },
() => {
test.beforeEach(async ({ comfyPage, templateApi }) => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
await comfyPage.settings.setSetting(
'Comfy.Templates.SelectedUseCases',
@@ -25,8 +25,6 @@ test.describe(
)
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
await templateApi.mockThumbnails()
})
test('displayed count matches visible cards when distribution filter excludes templates', async ({
@@ -56,7 +54,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -101,7 +99,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -143,7 +141,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -184,7 +182,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
@@ -222,7 +220,7 @@ test.describe(
})
])
)
await templateApi.mockIndex()
await templateApi.mock()
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1238,7 +1238,7 @@ test(
{ tag: '@vue-nodes' },
async ({ comfyMouse, comfyPage }) => {
async function performDisconnect(slot: Locator, isFast: boolean) {
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
if (!isFast) {
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
@@ -1251,7 +1251,7 @@ test(
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
const ksampler = new VueNodeFixture(ksamplerLocator)
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
await test.step('Disconnection with normal links', async () => {
await performDisconnect(ksampler.getSlot('model'), true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -234,7 +234,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')

View File

@@ -14,7 +14,8 @@ const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const getHeaderPos = async (
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number; width: number; height: number }> => {
): Promise<{ x: number; y: number }> => {
const box = await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
.first()
.boundingBox()
if (!box) throw new Error(`${title} header not found`)
return box
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
@@ -84,29 +84,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
test('should allow moving nodes by dragging', async ({
comfyPage,
comfyMouse
}) => {
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expectPosChanged(initialHeaderPos, newHeaderPos)
})
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
comfyPage,
comfyMouse
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
@@ -295,14 +293,12 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
// Re-fetch drag source after clicks in case the header reflowed.
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
const centerX = dragSrc.x + dragSrc.width / 2
const centerY = dragSrc.y + dragSrc.height / 2
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
steps: 20
})
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -42,7 +42,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await expect(pinIndicator2).toBeHidden()
})
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
test('should not allow dragging pinned nodes', async ({
comfyMouse,
comfyPage
}) => {
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
@@ -50,10 +53,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
// Try to drag the node
const headerPos = await checkpointNodeHeader.boundingBox()
if (!headerPos) throw new Error('Failed to get header position')
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Verify the node is not dragged (same position before and after click-and-drag)
await expect
@@ -64,11 +64,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
// Try to drag the node again
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Verify the node is dragged
await expect

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 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

@@ -5,12 +5,7 @@ import {
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
// Add a PreviewAny node which has a read-only textarea with a copy button
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')
window.app!.graph.add(node)
})
await comfyPage.searchBoxV2.addNode('Preview as Text')
await comfyPage.vueNodes.waitForNodes()
})

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.

25
global.d.ts vendored
View File

@@ -41,6 +41,29 @@ interface GtagFunction {
(...args: unknown[]): void
}
type SyftDataTraits = Record<string, string | number | null | undefined>
interface SyftDataPendingFetch {
args: unknown[]
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
}
interface SyftDataClient {
identify(email: string, traits?: SyftDataTraits): void
signup(email: string, traits?: SyftDataTraits): void
track(event: string, traits?: SyftDataTraits): void
page(...args: unknown[]): void
q?: unknown[][]
fi?: SyftDataPendingFetch[]
fetchID?: (...args: unknown[]) => Promise<unknown>
}
/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */
interface SyftDisabledClient {
enable: () => void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
@@ -78,6 +101,8 @@ interface Window {
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
syft?: SyftDataClient | SyftDisabledClient
syftc?: { sourceId?: string; enabled?: boolean }
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction

View File

@@ -53,7 +53,6 @@
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -1,10 +1,4 @@
import {
cleanupSVG,
importDirectorySync,
isEmptyColor,
parseColors,
runSVGO
} from '@iconify/tools'
import { cleanupSVG, importDirectorySync, runSVGO } from '@iconify/tools'
import { resolve } from 'node:path'
export const COMFY_ICON_PREFIX = 'comfy'
@@ -13,13 +7,6 @@ const COMFY_ICONS_DIR = resolve(import.meta.dirname, '../icons')
let cached
/**
* Load the comfy icon folder as a normalized Iconify icon set.
*
* Mirrors the pipeline that `@plugin "@iconify/tailwind4" { from-folder(...) }`
* runs internally so monotone hardcoded colors become `currentColor` and
* outer-svg attributes like `fill="none"` survive the body extraction.
*/
export function loadComfyIconSet() {
if (cached) return cached
const iconSet = importDirectorySync(COMFY_ICONS_DIR)
@@ -32,18 +19,6 @@ export function loadComfyIconSet() {
}
try {
cleanupSVG(svg)
const palette = parseColors(svg)
const colors = palette.colors.filter(
(color) => typeof color === 'string' || !isEmptyColor(color)
)
const totalColors = colors.length + (palette.hasUnsetColor ? 1 : 0)
if (totalColors < 2) {
parseColors(svg, {
defaultColor: 'currentColor',
callback: (_attr, colorStr, color) =>
!color || isEmptyColor(color) ? colorStr : 'currentColor'
})
}
runSVGO(svg)
iconSet.fromSVG(name, svg)
} catch {

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