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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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
- 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
NodeSlots.linkedWidgetedInputs and usePartitionedBadges still read
vueNodeData.inputs[].link directly; without refreshNodeInputs firing on
node:slot-links:changed those go stale on connect/disconnect.
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.
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.
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.
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.
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>
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>
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>
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>
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>
`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.
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.
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 }`.
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.
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.
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.
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.
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.