Compare commits

...

6 Commits

Author SHA1 Message Date
Alexander Brown
eb80183618 feat: nodeBadgeStore + badge system (slice A) (#13458)
## Summary

Extracts node badges into a dedicated store per ADR 0008 — slice A of
`docs/architecture/node-badge-store.md`: store, plain `BadgeData` rows,
reactive badge system, and LGraph chokepoint registration. Stacked on
#13455.

## Changes

- **What**:
- `src/types/badgeData.ts` — plain `BadgeData { kind, text, fgColor?,
bgColor?, iconKey? }` rows (decision 1).
- `src/stores/nodeBadgeStore.ts` — root-scoped buckets keyed by
`NodeId`, kind-ordered reads, identity-checked row deletes,
`registerNode`/`unregisterNode`/`clearGraph` registration trio; row
writes refused for unregistered nodes (decision 2).
- `src/systems/badgeSystem.ts` — pure `computeBadges(sources)` +
effect-scope-per-node shell watching `registeredNodeIds`; writes core
and credits rows from
nodeDef/settings/palette/pricing/widget-value/link-connectivity sources
(decisions 3–4).
- `LGraph.add`/`remove`/`clear` + subgraph-definition GC maintain the
store registration (linkStore/rerouteStore convention).
  - Design record updated with implementation notes.
- **Breaking**: none yet — `node.badges`/`useNodeBadge` closures
untouched; nothing starts the system in production until slice B's
consumer cutover.

## Review Focus

- Registration is bucket-key presence; the system discovers nodes by
watching `registeredNodeIds`, so litegraph never imports pricing/nodeDef
modules (no import cycle via the litegraph barrel).
- Core rows are fine-grained (lifecycle, id, source) under one
visibility rule — unification notes in the design record (Vue gains
HideBuiltIn on id badges; legacy join/truncation becomes slice-B
draw-cache presentation).
- Subgraph credits aggregation, the legacy `node.badges` surface, and
`badgePosition` are open decisions in the design record and deliberately
not in this PR.
- Slice A+B together would exceed the ~300-line guideline, hence the
split; store row APIs (`registerBadge`/`deleteBadge`) land now because
the slice-B shim consumes them.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:56:31 -07:00
Alexander Brown
0d4df04cf3 refactor: read widgeted-input connectivity from linkStore (#13455)
## Summary

Migrates the `inputs[].link` mirror-based reactivity tracking to
`linkStore` queries and deletes the slot-link reprojection machinery in
`useGraphNodeManager`.

## Changes

- **What**: `NodeSlots.linkedWidgetedInputs`, `usePartitionedBadges`'
pricing pokes, and `trackNodePrice` now query
`linkStore.isInputSlotConnected` (root graph id + unfiltered slot
index). Deletes the `node:slot-links:changed` handler and its
load-bearing comment, the emitter-less `node:slot-errors:changed`
handler, `refreshNodeInputs`, and the node-removal refresh-all loop.
Adds unified-mode NodeSlots tests (none existed) and a `trackNodePrice`
reactivity test. Drafts `docs/architecture/node-data-store.md` for the
next phase.
- **Not removed**: the `node:slot-links:changed` emitters and trigger
type entries, the `input.link` mirror itself, the computation-path
`inp.link` reads in `useNodePricing.buildJsonataContext` (migrated in
#13498 alongside the mirror deletion), and the `node:slot-label:changed`
handler — those belong to the Slot extraction / nodeDataStore phases.

## Review Focus

- The node-removal refresh-all loop deletion (own commit) — originally
shipped as a regression fix; gated locally on the vue-nodes legacy
widget e2e spec plus the full vueNodes suite (126 passed).
- Pricing badges previously only re-evaluated for widgeted inputs (the
trigger's gate); linkStore queries now also cover non-widgeted pricing
inputs — a correctness improvement, not parity.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:27:05 -07:00
Alexander Brown
368b5bedd4 ECS: derive slot floating links; reroute cleanup follow-ups (#13451)
## Summary

Follow-up cleanups from the reroute chain store work (#13449): deletes
the `slot._floatingLinks` mirror in favor of endpoint derivation, fixes
two position/endpoint integrity gaps the mirror had masked, and
restructures the subgraph-unpack chain stitching.

Stacked on #13449.

## Changes

- **What**:
- `slot._floatingLinks` Sets deleted — a floating link's attachment is
fully encoded in its own endpoints, so `slotFloatingLinks(network, side,
nodeId, slot)` derives it; ~25 write sites across
`addFloatingLink`/`removeFloatingLink`, `setFloatingLinkOrigin`, and
every `FloatingRenderLink` connect method are gone
- `moveInputLink`/`moveOutputLink` gain the `node` param their
`dragNewFrom*` siblings already take
- `FloatingRenderLink.connectToSubgraphOutput` now writes the link's
**target** end (it wrote `origin_id = SUBGRAPH_OUTPUT_ID`,
clobbering/skewing endpoints — masked by the mirror)
- `removeInput`/`removeOutput` renumber floating-link slot indices
alongside real links (stale indices previously persisted into serialized
floating links)
- `Reroute.snapToGrid` mirrors into the layout store like `move()` does
(Vue hit-testing read a stale spatial index after snapped drags)
- Subgraph-unpack reroute chain stitching rewritten as
collect-then-stitch (two segment walks + one generic pointer pass,
replacing three interleaved walk-and-write loops)

## Review Focus

- Scoped out with written assessments: `input.link`/`output.links`
mirror removal (roadmap-scale `SlotConnection` extraction, ~530
accesses) and reroute position ownership inversion (layout store only
seeds the active graph, no writeback)
- Corrupt-data error paths in unpack stitching are normalized to
per-link skip (the old code broke the whole loop on some
internal-segment failures)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:04:19 -07:00
Alexander Brown
3e458f5e6f ECS: Reroute chain store with derived link membership (#13449)
## Summary

Extracts reroute connectivity into a dedicated `rerouteStore` per ADR
0008: chain state (`parentId`, floating marker) is stored by reference,
and link membership (`linkIds`/`floatingLinkIds`) is derived from the
links' `parentId` chains instead of being hand-maintained at ~10 write
sites.

Stacked on #13436 (link topology store); design record in
`docs/architecture/reroute-chain-store.md`.

## Changes

- **What**:
- `LLink._state` now holds the link store's reactive proxy (BaseWidget
pattern), so `link.parentId` writes are tracked by Vue
- New `rerouteStore`: root-scoped buckets keyed by `RerouteId`,
`RerouteChain {id, parentId?, floating?}` registered by reference;
`Reroute` class reads/writes through the store proxy
- Membership derived via a per-graph computed reverse index over link
topologies + chain states; `Reroute.linkIds`/`floatingLinkIds` become
readonly derived accessors
- Deleted: all manual membership mutation sites,
`Reroute.validateLinks`, `Reroute.removeLink`, `Reroute.update`'s
linkIds param
- `LGraph._addReroute`/`_removeReroute` chokepoints mirror
`_addLink`/`_removeLink`
- Load-time reroute-id dedup across sibling subgraph definitions
(mirrors node-id dedup; reroute ids must now be per-root-unique)
- Serialization emits `linkIds` in ascending link-id order; goldens
added for byte-identical round-trip
- **Breaking**: `Reroute.linkIds`/`floatingLinkIds` are now
`ReadonlySet` accessors — external mutation of these sets no longer
affects membership (the chain is the single source of truth).
`Reroute.validateLinks`/`removeLink` removed.

## Review Focus

- `LLink.disconnect` now unregisters the link before pruning empty
reroutes, 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 (`LinkConnector.ts`)
- Workflows whose stored `linkIds` contradict their chains are repaired
rather than preserved (design decision 5; no shim)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 12:35:12 -07:00
Alexander Brown
9c7af27a52 ECS: Link topology store (single source of truth for link data) (#13436)
## Summary

Make a Pinia store (`useLinkStore`) the single source of truth for graph
link topology (ADR-0008 / ECS), mirroring `widgetValueStore`. `LLink`
topology fields become accessors over one `_state` object registered
into the store **by reference** — one copy, no mirror, no sync bridge.

## Changes

- **What**:
- New `useLinkStore` (`src/stores/linkStore.ts`) — **keyed 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 consumer queries by target, so the dominant query ("is this
input connected, and by what?") is one map lookup. Public surface is 6
actions (`registerLink`, `updateEndpoint`, `deleteLink`,
`isInputSlotConnected`, `getInputSlotLink`, `clearGraph`).
- Links without a unique target live in a per-graph side collection:
floating links (either endpoint unassigned; several can share an input
slot) and links into `SUBGRAPH_OUTPUT_ID` (a constant shared by every
subgraph in a root bucket). Neither is queried by target. Floating links
never answer `isInputSlotConnected`, matching `input.link` semantics.
- `LLink` topology backed by a single `_state: LinkTopology`; endpoint
setters delegate to `updateEndpoint` (displace → patch → re-place) when
registered. `updateEndpoint`/`deleteLink` take the topology by reference
and are identity-checked; `LLink._graphId` is set only while the link
actually holds a registration.
- `LGraph._addLink` / `_removeLink` chokepoints: every `_links` map
add/remove funnels through them (both `configure` loops, subgraph
reconnect, duplicate-link purge, `linkFixer`), keeping link-store and
layout entries in sync. Subgraph-definition removal and
non-root/unconfigured `clear()` unregister their links.
- Topology registered at every litegraph create site (`connectSlots`,
both `configure` loops, `addFloatingLink`, subgraph connect) and cleaned
up on `disconnect` / `removeFloatingLink` / `clear` / node-delete
cascade.
- Deleted the `layoutStore.ylinks` connectivity mirror +
`layoutMutations.createLink`/`deleteLink` +
`Create/DeleteLinkOperation`; node removal disconnects links before
`onNodeRemoved` fires, so layout cleanup needs no connectivity mirror or
layout→link cross-store call.
- Migrated the widget-disabled hot path (`WidgetItem.isLinked`,
`AppModeWidgetList`, `buildSlotMetadata`) to O(1) `isInputSlotConnected`
/ `getInputSlotLink`; removed the now-redundant `node:slot-links:changed
→ refreshNodeInputs` re-projection.
- Serialization round-trip goldens (plain / reroute-chain / floating /
subgraph). Because the store no longer keys by link id, subgraph link-id
collisions are irrelevant to it and **workflows load without any link-id
rewriting** (the node-id dedup for widget-store keys is pre-existing and
unchanged).
- **Breaking**: `LLink` topology fields (`origin_id`, `origin_slot`,
`target_id`, `target_slot`, `type`, `parentId`) are now accessors over
`_state`; `parentId` moves from own-property to prototype accessor
(`hasOwnProperty('parentId')` now returns `false`). `asSerialisable()`
output is byte-identical (guarded by goldens).

## Review Focus

- **Register-by-reference**: `LLink._state` is the exact object the
store holds — no second copy, no sync function. Verify no path writes
topology to two places.
- **Target-keying invariant**: one live link per input slot is enforced
by litegraph (`connectSlots` disconnects first); the store's side
collection covers exactly the links that violate or escape it (floating,
subgraph-output-node targets).
- Endpoint setters must delegate fully to `updateEndpoint` when
registered and mutate `_state` directly only when unregistered.
- Lifecycle completeness: every create path registers, every destroy
path unregisters — `_addLink`/`_removeLink` are the chokepoints.
- **Reroutes explicitly deferred.** Remaining `input.link` connectedness
*reads* (`nodeDataUtils`, `useNodePricing`, GLSL preview) are strangler
residue for a follow-up — not in scope here.

Stacked on #13322 — base is `drjkl/safe-widget-cleanup`.

## Migration note (extension authors)

`LLink` topology fields (`id`, `origin_id`, `origin_slot`, `target_id`,
`target_slot`, `type`, `parentId`) are now accessors over an internal
`_state` object, so they are non-enumerable on live instances.
`Object.keys(link)`, `JSON.stringify(link)`, `{...link}`, and
`structuredClone(link)` no longer expose them. To copy or serialize a
link, use `link.asSerialisable()` — direct field reads
(`link.origin_id`) are unchanged. Persisted workflow JSON is unaffected
(byte-identical, covered by goldens).

## Screenshots

Manual render-parity checklist (run against a ~200-node workflow): links
render; connect/disconnect updates live; undo/redo; subgraph nav. E2E
`nodeSelection.spec.ts` passes 13/13 including "a linked widget is
disabled". Note: 8 `linkInteraction.spec.ts` screenshot diffs (~0.01
ratio) observed — no commit in this branch touches link
geometry/coordinates/draw order, so these are
pre-existing/environmental; confirm against base before un-drafting.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-09 21:21:58 -07:00
Alexander Brown
14d55eb305 ECS: Moving widgets out of the Node data (#13322)
## Summary

Move Vue widget render-only metadata out of the graph node manager and
into dedicated widget render state alongside widget values.

## Changes

- **What**: Adds `WidgetRenderState` in `widgetValueStore`, removes
`SafeWidgetData` from `useGraphNodeManager`, and has Vue widget
rendering compose from raw LiteGraph widgets plus store-backed
value/render state.
- **What**: Updates promoted widget, app mode, parameter panel, preview,
tooltip, and widget tests for the new render-state boundary.
- **What**: Hardens related browser tests against branded id typing and
brittle default workflow node ids.

## Review Focus

Please look closely at the ownership boundary between raw LiteGraph
widgets, `WidgetState`, and `WidgetRenderState`, especially promoted
widget metadata and linked-widget rendering.

## Validation

- `pnpm typecheck`
- `pnpm typecheck:browser`
- focused widget/node unit tests
- focused Playwright check for `Should display added widgets`

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-09 10:42:30 -07:00
132 changed files with 6901 additions and 3276 deletions

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1270,3 +1270,57 @@ test(
})
}
)
test('Floating reroutes', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
const previewNodePos = { position: { x: 800, y: 200 } }
await comfyPage.searchBoxV2.addNode('Preview Image', previewNodePos)
const previewNode =
await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Create floating reroute', async () => {
const reroutePos = { targetPosition: { x: 700, y: 400 } }
await previewNode
.getSlot('images')
.first()
.dragTo(comfyPage.canvas, reroutePos)
await comfyPage.contextMenu.clickLitegraphMenuItem('Add Reroute')
await comfyPage.searchBoxV2.addNode('Load Image')
})
await test.step('Connect node on top of floating link', async () => {
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadNode
.getSlot('IMAGE')
.first()
.dragTo(previewNode.getSlot('images').first())
})
await test.step('Create node from floating reroute', async () => {
await comfyPage.canvas.dragTo(comfyPage.canvas, {
sourcePosition: { x: 680, y: 400 },
targetPosition: { x: 500, y: 500 }
})
await comfyPage.contextMenu.clickLitegraphMenuItem('LoadImage')
})
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
if (!graph || graph.links.size !== 1) return 'invalid link count'
if (graph.reroutes.size !== 1) return 'invalid reroutes count'
const linkId = graph.nodes.find((n) => n.title === 'Preview Image')
?.inputs[0].link
if (!linkId) return 'failed to resolve link id'
const rerouteId = graph.getLink(linkId)?.parentId
if (!rerouteId) return 'failed to resolve reroute id'
return !graph.reroutes.has(rerouteId) && 'reroute does not exist'
}),
'old link is disconnected, reroute is part of new connection'
)
.toBe(false)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

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

View File

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

View File

@@ -21,6 +21,31 @@ 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.
### Amendment (2026-07-14, PR 13458)
`nodeBadgeStore` joined the dedicated-store set: plain `BadgeData` rows
keyed by `NodeId` in root-graph-scoped buckets. Unlike the
proxy-adoption stores above, rows are written by a reactive badge
system (`src/systems/badgeSystem.ts`) — a pure `computeBadges` inside a
per-node effect scope — see
[Node Badge Store](../architecture/node-badge-store.md).
## 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 +140,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 +174,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 +189,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 +319,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,26 @@ 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` |
| `nodeBadgeStore` ✅ PR 13458 | `src/stores/nodeBadgeStore.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 +273,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 +332,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 +623,16 @@ 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 |
| `nodeBadgeStore` | `NodeId`, 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 +659,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,112 @@
# 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` and `updateEndpoint` are **identity-checked** (`toRaw`
comparison): only the registered topology can vacate or re-key its
slot.
- `updateEndpoint` re-keys atomically — displace, patch fields through
a reactive wrapper, re-place under the new key — and returns
`undefined` when the new target is already occupied. 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 `input.link` / `output.links`
slot mirrors remain the litegraph-native representation un-migrated
consumers read; extracting them is the `SlotConnection` component work
in the [ECS migration plan](ecs-migration-plan.md), not part of this
store.

View File

@@ -0,0 +1,168 @@
# Node Badge Store
Date: 2026-07-05 (updated 2026-07-06)
Status: Partially implemented — decisions 14 shipped as
`src/stores/nodeBadgeStore.ts` + `src/systems/badgeSystem.ts` (slice A);
decision 5 and the open decisions below await the consumer-cutover PR
(slice B). 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 a `resolveNode` seam and will be bootstrapped at the app layer
in slice B, when the `Comfy.NodeBadge` extension stops pushing
closures; until then rows are written only under test. Row writes are
refused for unregistered nodes so a late effect flush cannot
resurrect a bucket key the chokepoints deleted.
- Two write paths: `setBadgesOfKind` is the system's bulk
replace-one-kind recompute path (`@internal`); extension rows go
through `registerBadge`/`deleteBadge` per row, identity-checked, so
independent writers cannot stomp each other.
- 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 in lifecycle, id, source
order — with one visibility rule (`badgeTextVisible`, the legacy
semantics: `HideBuiltIn` respected for every part). Joining,
bracket decoration, and truncation are renderer presentation and move
to the slice-B legacy draw cache. Rows store lifecycle text with the
`[]` brackets trimmed. Known unification effects: the Vue renderer
gains `HideBuiltIn` handling for id badges, and core nodes' `🦊`
source row becomes data the Vue partition may substitute with its
Comfy-logo chip.
- A credits row is emitted only when the display price label is
non-empty; async pricing fills it via the per-node revision ref.
- Subgraph credits aggregation is not yet in the system — it stays on
the `updateSubgraphCredits` closure path until open decision 3 below
is resolved.
## Open decisions (interview pending)
1. **Legacy `node.badges` surface** — recommended: a converting
accessor exposing the node's `BadgeData[]` proxy; bare
`LGraphBadge`/thunk pushes auto-convert (thunk evaluated once,
`Image` icons dropped) with a one-time deprecation warning pointing
at the store API. Alternatives: delete the property outright, or a
read-only view. Ecosystem scan found zero genuine third-party
writers; ADR 0008 extension-impact guidance applies regardless.
2. **`node.badgePosition`** — recommended: delete (single writer sets a
constant `TopRight` on every node; single reader; renderer policy,
not badge data). Deprecated accessor warns on write.
3. **Subgraph credits aggregation triggers** — recommended: the three
existing events (`litegraph:set-graph`, `subgraph-converted`,
`afterConfigureGraph`) bump a revision the system watches; swap to
reactive `SubgraphStructure` dependencies when that state is
store-backed.
## 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,145 @@
# 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.
With their last listeners gone, both trigger actions are deleted
outright — emitters, event-map entries, and types (the badge system
sources connectivity from `linkStore`, not events; resurrect from git
if a consumer ever materialises). Readers get the root graph id from
`canvasStore.rootGraphId`, the shared tracked accessor, rather than
per-site `canvas?.graph?.rootGraph.id` chains.
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

@@ -6,20 +6,36 @@ 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,
Nine 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 |
| NodeBadgeStore | `LGraphNode.badges` closures | Root graph | `NodeId` | Plain `BadgeData` rows |
**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).
**Update (2026-07-14):** `NodeBadgeStore` (`src/stores/nodeBadgeStore.ts`,
PR #13458) holds plain `BadgeData` rows keyed by `NodeId` in root-graph-scoped
buckets, written by a reactive badge system rather than adopted class state.
Design record: [Node Badge Store](node-badge-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 +165,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 +259,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 +313,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 +377,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 +397,11 @@ Based on existing progress and problem severity:
1. **Widget** — closest to done (value extraction complete, needs rendering/layout extraction)
2. **Node Position** — already in LayoutStore, needs branded ID and formal component type
3. **Link**small component set, high coupling pain
3. **Link** — ✅ topology shipped (LinkStore, PR #13436); slot mirrors
(`input.link`/`output.links`) and visual state remain
4. **Slot** — no extraction yet, but small and self-contained
5. **Reroute** — partially extracted, moderate complexity
(`SlotConnection` input side now answerable via LinkStore)
5. **Reroute** — ✅ chain shipped (RerouteStore, PR #13449); position
ownership and visual remain
6. **Group** — no extraction, but least coupled to other entities
7. **Subgraph** — not a separate entity kind; SubgraphStructure and SubgraphMeta become node components. Depends on Node and Link extraction first. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md)

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -256,7 +256,10 @@ function clearWidgetErrors(
source.sourceWidgetName,
source.sourceWidgetName,
value,
options
{
min: source.sourceWidget.options?.min,
max: source.sourceWidget.options?.max
}
)
}

View File

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

View File

@@ -3,8 +3,6 @@ import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -15,13 +13,18 @@ import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useLinkStore } from '@/stores/linkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedWidget,
WidgetValue as SimplifiedWidgetValue
} from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -61,6 +64,7 @@ const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const widgetValueStore = useWidgetValueStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const linkStore = useLinkStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
@@ -69,16 +73,14 @@ const widgetComponent = computed(() => {
})
const isLinked = computed(() => {
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widget.name)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
})
const simplifiedWidget = computed((): SimplifiedWidget => {
const graphId = node.graph?.rootGraph?.id
const graphId = node.graph?.rootGraph.id
const bareNodeId = stripGraphPrefix(node.id)
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
@@ -93,7 +95,9 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
return {
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
value: (widgetState
? widgetState.value
: widget.value) as SimplifiedWidgetValue,
label: widgetState?.label ?? widget.label,
options: { ...baseOptions, disabled },
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),

View File

@@ -3,7 +3,10 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import {
extractVueNodeData,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import {
@@ -14,6 +17,7 @@ import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { linkedWidgetedInputs } from '@/renderer/extensions/vueNodes/utils/nodeDataUtils'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
@@ -62,15 +66,29 @@ describe('Node Reactivity', () => {
expect(onValueChange).toHaveBeenCalledTimes(1)
})
it('does not re-wrap node.widgets on repeated extraction', () => {
const { node } = createTestGraph()
const widgetsGetter = () =>
Object.getOwnPropertyDescriptor(node, 'widgets')?.get
extractVueNodeData(node)
const firstGetter = widgetsGetter()
extractVueNodeData(node)
extractVueNodeData(node)
expect(widgetsGetter()).toBe(firstGetter)
})
it('widget values remain reactive after a connection is made', async () => {
const { node, graph } = createTestGraph()
const store = useWidgetValueStore()
const onValueChange = vi.fn()
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT
})
const upstream = new LGraphNode('upstream')
upstream.addOutput('out', 'INT')
graph.add(upstream)
upstream.connect(0, node, 0)
await nextTick()
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
@@ -87,7 +105,7 @@ describe('Node Reactivity', () => {
})
})
describe('Widget slotMetadata reactivity on link disconnect', () => {
describe('Widget input link reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -96,10 +114,8 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
graph.add(node)
@@ -112,81 +128,14 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
return { graph, node, upstream, linkId: link.id }
}
it('sets slotMetadata.linked to true when input has a link', () => {
it('exposes linked widget input slots through Vue node inputs', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
expect(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
@@ -205,15 +154,14 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
expect(
linkedWidgetedInputs(nodeData!, subgraph.rootGraph.id).map((s) => s.name)
).toEqual(['prompt'])
})
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
it('registers promoted widget render state separately from value state', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
@@ -229,39 +177,21 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
useGraphNodeManager(graph)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
})
const id = widgetId(graph.id, subgraphNode.id, 'value')
const store = useWidgetValueStore()
const valueState = store.getWidget(id)
const renderState = store.getWidgetRenderState(id)
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
expect(valueState?.name).toBe('value')
expect(valueState?.value).toBe('hello')
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
})
})
@@ -368,15 +298,13 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
useGraphNodeManager(graph)
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
const id = widgetId(graph.id, subgraphNodeB.id, 'b_input')
const state = useWidgetValueStore().getWidget(id)
expect(state?.type).toBe('combo')
expect(subgraphNodeB.widgets[0]?.widgetId).toBe(id)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
@@ -405,27 +333,23 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
useGraphNodeManager(graph)
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
const ids = subgraphNode.widgets.map((widget) => widget.widgetId)
expect(ids).toStrictEqual([
widgetId(graph.id, subgraphNode.id, 'first_seed'),
widgetId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
])
expect(ids[0]).not.toBe(ids[1])
})
})
describe('Promoted widget sourceExecutionId', () => {
describe('Promoted widget render state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
it('registers plain render metadata for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
@@ -451,22 +375,21 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
useGraphNodeManager(graph)
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, subgraphNode.id, 'ckpt_input')
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
`${subgraphNode.id}:${interiorNode.id}`
)
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
it('does not set sourceExecutionId for non-promoted widgets', () => {
it('registers plain render metadata for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
@@ -474,12 +397,14 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
useGraphNodeManager(graph)
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, node.id, 'steps')
)
expect(renderState).toBeDefined()
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
})

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ export function useUpstreamValue<T>(
return computed(() => {
const upstream = getLinkedUpstream()
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
const graphId = canvasStore.rootGraphId
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
return extractValue(widgets, upstream.outputName)

View File

@@ -145,6 +145,13 @@ function applySubgraphInputOrder(
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
useWidgetValueStore().setNodeWidgetOrder(
subgraphNode.rootGraph.id,
subgraphNode.id,
subgraphNode.inputs.flatMap((input) =>
input.widgetId ? [input.widgetId] : []
)
)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
@@ -281,22 +288,26 @@ function seedNestedPromotedInputState(
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
const store = useWidgetValueStore()
const sourceState = store.getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
store.registerWidget(
id,
{
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled
},
store.getWidgetRenderState(sourceSlot.widgetId) ?? {}
)
}
function promotePreviewViaExposure(

View File

@@ -11,7 +11,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import {
getWidgetIds,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -48,6 +51,16 @@ type AutogrowNode = LGraphNode &
}
}
function syncNodeWidgetOrder(node: LGraphNode) {
const graphId = resolveNodeRootGraphId(node)
if (!graphId || !node.widgets) return
useWidgetValueStore().setNodeWidgetOrder(
graphId,
node.id,
getWidgetIds(node.widgets)
)
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
node.widgets ??= []
const { widget } = input
@@ -105,7 +118,10 @@ function dynamicComboWidget(
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) return
if (!newSpec) {
syncNodeWidgetOrder(node)
return
}
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
@@ -140,6 +156,7 @@ function dynamicComboWidget(
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
syncNodeWidgetOrder(node)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
@@ -541,8 +558,11 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
for (const widget of remove(node.widgets, (w) => w.name === widgetName)) {
widget.onRemove?.()
if (widget.widgetId) useWidgetValueStore().deleteWidget(widget.widgetId)
}
syncNodeWidgetOrder(node)
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

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

View File

@@ -0,0 +1,88 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
import { createUuidv4 } from '@/utils/uuid'
import {
createTestSubgraphData,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraph node badge registration', () => {
it('registers a node in the root bucket on add, unregisters on remove', () => {
const graph = new LGraph()
const node = new LGraphNode('n')
graph.add(node)
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual([
node.id
])
graph.remove(node)
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual(
[]
)
})
it('clears the root bucket when the root graph is cleared', () => {
const graph = new LGraph()
graph.id = createUuidv4()
graph.add(new LGraphNode('a'))
graph.add(new LGraphNode('b'))
const graphId = graph.rootGraph.id
graph.clear()
expect(useNodeBadgeStore().registeredNodeIds(graphId)).toEqual([])
})
it('registers subgraph nodes in the root bucket', () => {
const rootGraph = new LGraph()
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
const inner = new LGraphNode('inner')
subgraph.add(inner)
expect(
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
).toContainEqual(inner.id)
})
it('unregisters nodes at every nesting depth when a subgraph is cleared', () => {
const rootGraph = new LGraph()
const keeper = new LGraphNode('keeper')
rootGraph.add(keeper)
const outer = rootGraph.createSubgraph(createTestSubgraphData())
outer.add(new LGraphNode('direct'))
const nested = rootGraph.createSubgraph(createTestSubgraphData())
nested.add(new LGraphNode('deep'))
outer.add(createTestSubgraphNode(nested, { parentGraph: outer }))
outer.clear()
expect(
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
).toEqual([keeper.id])
})
it('unregisters inner nodes when the subgraph definition is collected', () => {
const rootGraph = new LGraph()
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
const inner = new LGraphNode('inner')
subgraph.add(inner)
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
rootGraph.add(subgraphNode)
rootGraph.remove(subgraphNode)
expect(
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
).toEqual([])
})
})

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { toString } from 'es-toolkit/compat'
import { shallowRef, toRaw } from 'vue'
import {
SUBGRAPH_INPUT_ID,
@@ -8,9 +9,13 @@ import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/utils/uuid'
import { createUuidv4, zeroUuid } from '@/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { useLinkStore } from '@/stores/linkStore'
import { useRerouteStore } from '@/stores/rerouteStore'
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, parseNodeId, toNodeId } from '@/types/nodeId'
@@ -29,10 +34,20 @@ import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import type { GroupId } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import { LLink } from './LLink'
import {
LLink,
registerLinkTopology,
unregisterAllLinkTopologies,
unregisterLinkTopology
} from './LLink'
import type { LinkId } from './LLink'
import { MapProxyHandler } from './MapProxyHandler'
import { Reroute } from './Reroute'
import {
registerRerouteChain,
Reroute,
unregisterAllRerouteChains,
unregisterRerouteChain
} from './Reroute'
import type { RerouteId } from './Reroute'
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
@@ -59,6 +74,7 @@ import {
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -92,6 +108,7 @@ import type {
import { getAllNestedItems } from './utils/collections'
import {
deduplicateSubgraphNodeIds,
deduplicateSubgraphRerouteIds,
topologicalSortSubgraphs
} from './subgraph/subgraphDeduplication'
@@ -181,6 +198,55 @@ function fireNodeRemovalLifecycle(node: LGraphNode): void {
graph?.onNodeRemoved?.(node)
}
/** A reroute chain segment, terminal-first. */
interface ChainSegment {
/** Emitted reroute ids, in walk order. */
segment: RerouteId[]
/** `false` if the walk stopped at a broken reference or a cycle. */
complete: boolean
}
/**
* Resolves one hop of a reroute chain.
* @param id The reroute id to resolve.
* @returns The id to emit and the next id upstream, or `undefined` if the
* reference is broken.
*/
type ChainStep = (
id: RerouteId
) => { emit: RerouteId; next: RerouteId | undefined } | undefined
/**
* Walks a reroute chain, resolving each hop with `step`, until it runs out,
* hits a broken reference, or detects a cycle.
* @param start The reroute id to walk from, or `undefined` for an empty chain.
* @param step Resolves each hop of the chain.
* @returns The walked segment.
*/
function walkSegment(
start: RerouteId | undefined,
step: ChainStep
): ChainSegment {
const segment: RerouteId[] = []
const visited = new Set<RerouteId>()
let id = start
while (id !== undefined) {
if (visited.has(id)) {
console.error('Infinite parentId loop when unpacking')
return { segment, complete: false }
}
visited.add(id)
const hop = step(id)
if (!hop) {
console.error('Broken Id link when unpacking')
return { segment, complete: false }
}
segment.push(hop.emit)
id = hop.next
}
return { segment, complete: true }
}
/**
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
* supported callbacks:
@@ -223,7 +289,18 @@ export class LGraph
'extra'
])
id: UUID = zeroUuid
/**
* Ref-backed so the id reassignment on every workflow load ({@link configure})
* propagates to reactive consumers keyed by root graph id.
*/
private readonly _id = shallowRef<UUID>(zeroUuid)
get id(): UUID {
return toRaw(this)._id.value
}
set id(value: UUID) {
toRaw(this)._id.value = value
}
revision: number = 0
_version: number = -1
@@ -395,6 +472,17 @@ export class LGraph
if (this.isRootGraph && graphId !== zeroUuid) {
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
useLinkStore().clearGraph(graphId)
useRerouteStore().clearGraph(graphId)
useNodeBadgeStore().clearGraph(graphId)
} else {
// Subgraphs and unconfigured (zero-uuid) graphs share their store
// bucket with other graphs, so unregister each link individually.
unregisterAllLinkTopologies(this)
unregisterAllRerouteChains(this)
forEachNode(this, (node) =>
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
)
}
this.id = zeroUuid
@@ -1003,12 +1091,20 @@ export class LGraph
node.graph = this
this.incrementVersion()
useNodeBadgeStore().registerNode(this.rootGraph.id, node.id)
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
const widgetValueStore = useWidgetValueStore()
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
widgetValueStore.setNodeWidgetOrder(
this.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}
this._nodes.push(node)
@@ -1112,6 +1208,11 @@ export class LGraph
if (!hasRemainingReferences) {
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
forEachNode(node.subgraph, (innerNode) =>
useNodeBadgeStore().unregisterNode(this.rootGraph.id, innerNode.id)
)
unregisterAllLinkTopologies(node.subgraph)
unregisterAllRerouteChains(node.subgraph)
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}
@@ -1138,6 +1239,7 @@ export class LGraph
if (pos != -1) this._nodes.splice(pos, 1)
delete this._nodes_by_id[node.id]
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
this.onNodeRemoved?.(node)
@@ -1422,49 +1524,48 @@ export class LGraph
link.id = toLinkId(++this._lastFloatingLinkId)
}
this.floatingLinksInternal.set(link.id, link)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks ??= new Set()
slot._floatingLinks.add(link)
} else {
console.warn(
`Adding invalid floating link: target/slot: [${link.target_id}/${link.target_slot}] origin/slot: [${link.origin_id}/${link.origin_slot}]`
)
}
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.add(link.id)
}
registerLinkTopology(this, link)
return link
}
removeFloatingLink(link: LLink): void {
this.floatingLinksInternal.delete(link.id)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks?.delete(link)
}
unregisterLinkTopology(link)
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.delete(link.id)
if (reroute.floatingLinkIds.size === 0) {
delete reroute.floating
reroute.floating = undefined
}
if (reroute.totalLinks === 0) this.removeReroute(reroute.id)
}
}
/**
* Adds a link to this graph's {@link _links} map and registers its topology
* with the link store. The single entry point for populating {@link _links};
* routing every add through here keeps the store from silently desyncing.
*/
_addLink(link: LLink): void {
this._links.set(link.id, link)
registerLinkTopology(this, link)
}
/**
* Removes a link from this graph's {@link _links} map and unregisters it
* from the link and layout stores. The delete-side counterpart to
* {@link _addLink}; routing every removal through here keeps the stores
* from silently desyncing.
*/
_removeLink(linkId: LinkId): void {
const link = this._links.get(linkId)
if (!link) return
this._links.delete(linkId)
unregisterLinkTopology(link)
layoutStore.deleteLinkLayout(linkId)
}
/**
* Finds the link with the provided ID.
* @param id ID of link to find
@@ -1487,6 +1588,32 @@ export class LGraph
return id == null ? undefined : this.reroutes.get(id)
}
/**
* Adds a reroute to this graph's {@link reroutes} map and registers its
* chain state with the reroute store. The single entry point for
* populating {@link reroutes}; routing every add through here keeps the
* store from silently desyncing.
*/
_addReroute(reroute: Reroute): void {
this.reroutesInternal.set(reroute.id, reroute)
registerRerouteChain(this, reroute)
}
/**
* Removes a reroute from this graph's {@link reroutes} map and
* unregisters it from the reroute and layout stores. The delete-side
* counterpart to {@link _addReroute}.
*/
_removeReroute(id: RerouteId): void {
const reroute = this.reroutesInternal.get(id)
if (!reroute) return
this.reroutesInternal.delete(id)
unregisterRerouteChain(reroute)
const layoutMutations = useLayoutMutations()
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
}
/**
* Configures a reroute on the graph where ID is already known (probably deserialisation).
* Creates the object if it does not exist.
@@ -1496,7 +1623,6 @@ export class LGraph
id,
parentId,
pos,
linkIds,
floating
}: OptionalProps<SerialisableReroute, 'id'>): Reroute {
const rerouteId =
@@ -1508,11 +1634,11 @@ export class LGraph
}
const reroute = this.reroutes.get(rerouteId) ?? new Reroute(rerouteId, this)
const typedParentId =
reroute.parentId =
parentId === undefined ? undefined : toRerouteId(parentId)
const typedLinkIds = linkIds?.map(toLinkId)
reroute.update(typedParentId, pos, typedLinkIds, floating)
this.reroutes.set(rerouteId, reroute)
if (pos) reroute.pos = pos
reroute.floating = floating
this._addReroute(reroute)
return reroute
}
@@ -1530,41 +1656,24 @@ export class LGraph
}
const rerouteId = toRerouteId(Number(this.state.lastRerouteId) + 1)
this.state.lastRerouteId = rerouteId
const linkIds = before instanceof Reroute ? before.linkIds : [before.id]
const floatingLinkIds =
before instanceof Reroute ? before.floatingLinkIds : [before.id]
const reroute = new Reroute(
rerouteId,
this,
pos,
before.parentId,
linkIds,
floatingLinkIds
)
this.reroutes.set(rerouteId, reroute)
const chainLinks =
before instanceof Reroute
? [
...[...before.linkIds].map((id) => this._links.get(id)),
...[...before.floatingLinkIds].map((id) =>
this.floatingLinks.get(id)
)
]
: [before]
const reroute = new Reroute(rerouteId, this, pos, before.parentId)
this._addReroute(reroute)
// Register reroute in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createReroute(
rerouteId,
{ x: pos[0], y: pos[1] },
before.parentId,
Array.from(linkIds)
)
layoutMutations.createReroute(rerouteId, { x: pos[0], y: pos[1] })
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
const reroutes = LLink.getReroutes(this, link)
for (const x of reroutes.filter((x) => x.parentId === before.parentId)) {
x.parentId = rerouteId
}
}
for (const linkId of floatingLinkIds) {
const link = this.floatingLinks.get(linkId)
// Splice the new reroute into every chain that contained `before`
for (const link of chainLinks) {
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
@@ -1582,7 +1691,6 @@ export class LGraph
* @param id ID of reroute to remove
*/
removeReroute(id: RerouteId): void {
const layoutMutations = useLayoutMutations()
const { reroutes } = this
const reroute = reroutes.get(id)
if (!reroute) return
@@ -1625,11 +1733,7 @@ export class LGraph
}
}
reroutes.delete(id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
this._removeReroute(id)
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
// https://github.com/Comfy-Org/litegraph.js/issues/898
@@ -1667,9 +1771,7 @@ export class LGraph
const node = this.getNodeById(sampleLink.target_id)
const keepId = selectSurvivorLink(ids, node)
purgeOrphanedLinks(ids, keepId, this._links, (id) =>
this.getNodeById(toNodeId(id))
)
purgeOrphanedLinks(ids, keepId, this)
repairInputLinks(ids, keepId, node)
}
}
@@ -1908,7 +2010,7 @@ export class LGraph
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
link.origin_id = subgraphNode.id
link.origin_slot = i - 1
this.links.set(link.id, link)
this._addLink(link)
if (subgraphOutput instanceof SubgraphOutput) {
subgraphOutput.connect(
subgraphNode.findOutputSlotByType(link.type, true, true),
@@ -2042,30 +2144,6 @@ export class LGraph
group.pos[1] += offsetY
toSelect.push(group)
}
//cleanup reoute.linkIds now, but leave link.parentIds dangling
for (const islot of subgraphNode.inputs) {
if (!islot.link) continue
const link = this.links.get(islot.link)
if (!link) {
console.warn('Broken link', islot, islot.link)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
for (const oslot of subgraphNode.outputs) {
for (const linkId of oslot.links ?? []) {
const link = this.links.get(linkId)
if (!link) {
console.warn('Broken link', oslot, linkId)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
}
const newLinks: {
oid: NodeId
oslot: number
@@ -2200,89 +2278,52 @@ export class LGraph
}
newLink.id = created.id
}
// Migrate the subgraph's reroutes to fresh ids at their new positions.
const rerouteIdMap = new Map<RerouteId, RerouteId>()
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
if (
reroute.parentId !== undefined &&
rerouteIdMap.get(reroute.parentId) === undefined
) {
console.error('Missing Parent ID')
}
const migratedRerouteId = toRerouteId(
Number(this.state.lastRerouteId) + 1
)
this.state.lastRerouteId = migratedRerouteId
const migratedReroute = new Reroute(migratedRerouteId, this, [
const oldReroutes = subgraphNode.subgraph.reroutes
for (const reroute of oldReroutes.values()) {
const migratedId = toRerouteId(Number(this.state.lastRerouteId) + 1)
this.state.lastRerouteId = migratedId
const migratedReroute = new Reroute(migratedId, this, [
reroute.pos[0] + offsetX,
reroute.pos[1] + offsetY
])
rerouteIdMap.set(reroute.id, migratedReroute.id)
this.reroutes.set(migratedReroute.id, migratedReroute)
rerouteIdMap.set(reroute.id, migratedId)
this._addReroute(migratedReroute)
toSelect.push(migratedReroute)
}
//iterate over newly created links to update reroute parentIds
// Stitch each link's chain from its internal (migrated) and external
// segments, ordered by which side was nearest the input. External hops walk
// this graph's own reroutes; internal hops walk the old subgraph chain,
// emitting migrated ids.
for (const newLink of dedupedNewLinks) {
const linkInstance = this.links.get(newLink.id)
if (!linkInstance) {
continue
}
let instance: Reroute | LLink | undefined = linkInstance
let parentId: RerouteId | undefined
if (newLink.externalFirst) {
parentId = newLink.eparent
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
if (!instance) continue
parentId = newLink.iparent
while (parentId) {
const migratedId = rerouteIdMap.get(parentId)
if (!migratedId) {
console.error('Broken Id link when unpacking')
break
}
instance.parentId = migratedId
instance = this.reroutes.get(migratedId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
if (!oldReroute) {
console.error('Broken Id link when unpacking')
break
}
parentId = oldReroute.parentId
}
if (!instance) break
if (!newLink.externalFirst) {
parentId = newLink.eparent
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
if (!linkInstance) continue
const internal = walkSegment(newLink.iparent, (id) => {
const emit = rerouteIdMap.get(id)
return emit === undefined
? undefined
: { emit, next: oldReroutes.get(id)?.parentId }
})
const external = walkSegment(newLink.eparent, (id) => {
const reroute = this.reroutes.get(id)
return reroute && { emit: id, next: reroute.parentId }
})
const [first, second] = newLink.externalFirst
? [external, internal]
: [internal, external]
const chain = first.complete
? [...first.segment, ...second.segment]
: first.segment
let segmentEnd: LLink | Reroute = linkInstance
for (const rerouteId of chain) {
segmentEnd.parentId = rerouteId
const next = this.reroutes.get(rerouteId)
if (!next) break
segmentEnd = next
}
}
@@ -2471,7 +2512,6 @@ export class LGraph
data: ISerialisedGraph | SerialisableGraph,
keep_old?: boolean
): boolean | undefined {
const layoutMutations = useLayoutMutations()
const options: LGraphEventMap['configuring'] = {
data,
clearGraph: !keep_old
@@ -2495,7 +2535,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.createFromArray(linkData)
this._links.set(link.id, link)
this._addLink(link)
}
}
// #region `extra` embeds for v0.4
@@ -2536,7 +2576,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.create(linkData)
this._links.set(link.id, link)
this._addLink(link)
}
}
@@ -2590,6 +2630,20 @@ export class LGraph
)
: undefined
if (deduplicated) {
const reservedRerouteIds = new Set<number>()
for (const reroute of this.reroutes.values())
reservedRerouteIds.add(Number(reroute.id))
for (const sg of this.subgraphs.values())
for (const reroute of sg.reroutes.values())
reservedRerouteIds.add(Number(reroute.id))
deduplicateSubgraphRerouteIds(
deduplicated.subgraphs,
reservedRerouteIds,
this.state
)
}
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
@@ -2659,14 +2713,10 @@ export class LGraph
}
}
// Drop broken reroutes
// Drop reroutes that no live link or floating link passes through
for (const reroute of this.reroutes.values()) {
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
// Clean up layout store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
if (reroute.totalLinks === 0) {
this._removeReroute(reroute.id)
}
}

View File

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

View File

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

View File

@@ -93,7 +93,7 @@ describe('drawConnections widget-input slot positioning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ import { LGraphNode } from './LGraphNode'
import type { NodeProperty } from './LGraphNode'
import { parseNodeId, serializeNodeId } from '@/types/nodeId'
import type { SerializedNodeId } from '@/types/nodeId'
import { LLink } from './LLink'
import { LLink, slotFloatingLinks } from './LLink'
import type { LinkId } from './LLink'
import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
@@ -2784,13 +2784,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
output: INodeOutputSlot,
network: LinkNetwork
): boolean {
const outputLinks = [
...(output.links ?? []),
...[...(output._floatingLinks ?? new Set())]
]
return outputLinks.some(
(linkId) =>
typeof linkId === 'number' && network.getLink(linkId) !== undefined
return (output.links ?? []).some(
(linkId) => network.getLink(linkId) !== undefined
)
}
@@ -2801,7 +2796,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) {
// Drag multiple output links
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
linkConnector.moveOutputLink(graph, output)
linkConnector.moveOutputLink(graph, node, output)
this._linkConnectorDrop()
return
}
@@ -2847,12 +2842,15 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctrlOrMeta &&
e.altKey &&
!e.shiftKey
if (input.link !== null || input._floatingLinks?.size) {
if (
input.link !== null ||
slotFloatingLinks(graph, 'input', node.id, i).length > 0
) {
// Existing link
if (shouldBreakLink || LiteGraph.click_do_break_link_to) {
node.disconnectInput(i, true)
} else if (e.shiftKey || this.allow_reconnect_links) {
linkConnector.moveInputLink(graph, input)
linkConnector.moveInputLink(graph, node, input)
}
}
@@ -4294,14 +4292,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
// Remap linkIds
for (const reroute of reroutes.values()) {
const ids = [...reroute.linkIds].map((x) => links.get(x)?.id ?? x)
reroute.update(reroute.parentId, undefined, ids, reroute.floating)
// Remove any invalid items
if (!reroute.validateLinks(graph.links, graph.floatingLinks)) {
// Remove reroutes that no pasted link passes through
for (const [sourceId, reroute] of reroutes) {
if (reroute.totalLinks === 0) {
graph.removeReroute(reroute.id)
reroutes.delete(sourceId)
const index = created.indexOf(reroute)
if (index !== -1) created.splice(index, 1)
}
}

View File

@@ -9,6 +9,7 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { adjustColor } from '@/utils/colorUtil'
@@ -30,7 +31,8 @@ import { BadgePosition, LGraphBadge } from './LGraphBadge'
import { LGraphButton } from './LGraphButton'
import type { LGraphButtonOptions } from './LGraphButton'
import { LGraphCanvas } from './LGraphCanvas'
import { LLink } from './LLink'
import { LLink, slotFloatingLinks } from './LLink'
import { anchorRerouteChain } from './Reroute'
import type { Reroute, RerouteId } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw'
@@ -96,6 +98,7 @@ import type {
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -1687,6 +1690,15 @@ export class LGraphNode
}
}
}
if (this.graph) {
for (const floatingLink of this.graph.floatingLinks.values()) {
if (
floatingLink.origin_id === this.id &&
floatingLink.origin_slot > slot
)
floatingLink.origin_slot--
}
}
this.onOutputRemoved?.(slot)
this.setDirtyCanvas(true, true)
@@ -1741,6 +1753,15 @@ export class LGraphNode
if (link) link.target_slot--
}
}
if (this.graph) {
for (const floatingLink of this.graph.floatingLinks.values()) {
if (
floatingLink.target_id === this.id &&
floatingLink.target_slot > slot
)
floatingLink.target_slot--
}
}
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)
}
@@ -2055,6 +2076,20 @@ export class LGraphNode
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
const graphId = this.graph?.rootGraph.id
if (graphId) {
const widgetValueStore = useWidgetValueStore()
// Drop the widget from the render order but keep its stored value, so a
// remove-then-re-add of the same widget id preserves what the user set.
if (widget.widgetId)
widgetValueStore.removeNodeWidgetOrder(widget.widgetId)
widgetValueStore.setNodeWidgetOrder(
graphId,
this.id,
getWidgetIds(this.widgets)
)
}
}
ensureWidgetRemoved(widget: IBaseWidget): void {
@@ -2891,8 +2926,6 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
const layoutMutations = useLayoutMutations()
const outputIndex = this.outputs.indexOf(output)
if (outputIndex === -1) {
console.warn('connectSlots: output not found')
@@ -2935,7 +2968,7 @@ export class LGraphNode
// if there is something already plugged there, disconnect
if (inputNode.inputs[inputIndex]?.link != null) {
graph.beforeChange()
inputNode.disconnectInput(inputIndex, true)
inputNode.disconnectInput(inputIndex, true, afterRerouteId)
}
const maybeCommonType =
@@ -2955,52 +2988,15 @@ export class LGraphNode
)
// add to graph links list
graph._links.set(link.id, link)
// Register link in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createLink(
link.id,
this.id,
outputIndex,
inputNode.id,
inputIndex
)
graph._addLink(link)
// connect in output
output.links ??= []
output.links.push(link.id)
// connect in input
const targetInput = inputNode.inputs[inputIndex]
targetInput.link = link.id
if (targetInput.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: inputNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
}
inputNode.inputs[inputIndex].link = link.id
// Reroutes
const reroutes = LLink.getReroutes(graph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) reroute.floating = undefined
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = graph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
graph.removeFloatingLink(link)
}
}
}
anchorRerouteChain(graph, link)
graph.incrementVersion()
// link has been created now, so its updated
@@ -3075,7 +3071,6 @@ export class LGraphNode
if (!link)
throw new Error('[connectFloatingReroute] Floating link not found')
reroute.floatingLinkIds.add(link.id)
link.parentId = reroute.id
parentReroute.floating = undefined
return reroute
@@ -3106,11 +3101,14 @@ export class LGraphNode
const output = this.outputs[slot]
if (!output) return false
if (output._floatingLinks) {
for (const link of output._floatingLinks) {
if (link.hasOrigin(this.id, slot)) {
this.graph?.removeFloatingLink(link)
}
if (this.graph) {
for (const link of slotFloatingLinks(
this.graph,
'output',
this.id,
slot
)) {
this.graph.removeFloatingLink(link)
}
}
@@ -3138,15 +3136,6 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove there
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
@@ -3194,15 +3183,6 @@ export class LGraphNode
const input = target.inputs[link_info.target_slot]
// remove other side link
input.link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id
})
}
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
@@ -3235,9 +3215,15 @@ export class LGraphNode
* Disconnect one input
* @param slot Input slot index, or the name of the slot
* @param keepReroutes If `true`, reroutes will not be garbage collected.
* @param keepFloatingReroute Floating link(s) parented to this reroute are left
* intact, so a chain being reconnected is not pruned before its new link exists.
* @returns true if disconnected successfully or already disconnected, otherwise false
*/
disconnectInput(slot: number | string, keepReroutes?: boolean): boolean {
disconnectInput(
slot: number | string,
keepReroutes?: boolean,
keepFloatingReroute?: RerouteId
): boolean {
// Allow search by string
if (typeof slot === 'string') {
slot = this.findInputSlot(slot)
@@ -3262,25 +3248,16 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
// Break floating links
if (input._floatingLinks?.size) {
for (const link of input._floatingLinks) {
graph.removeFloatingLink(link)
}
// Break floating links, except the one whose reroute chain is being
// reconnected (its reroute would be pruned before the new link is added).
for (const link of slotFloatingLinks(graph, 'input', this.id, slot)) {
if (link.parentId === keepFloatingReroute) continue
graph.removeFloatingLink(link)
}
const link_id = this.inputs[slot].link
if (link_id != null) {
this.inputs[slot].link = null
if (input.widget) {
graph.trigger('node:slot-links:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT,
slotIndex: slot,
connected: false,
linkId: link_id
})
}
// remove other side
const link_info = graph._links.get(link_id)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { remove } from 'es-toolkit'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import {
SUBGRAPH_INPUT_ID,
@@ -135,6 +135,7 @@ export class LinkConnector {
/** Drag an existing link to a different input. */
moveInputLink(
network: LinkNetwork,
node: LGraphNode,
input: INodeInputSlot,
opts?: { startPoint?: Point }
): void {
@@ -145,7 +146,12 @@ export class LinkConnector {
const linkId = input.link
if (linkId == null) {
// No link connected, check for a floating link
const floatingLink = input._floatingLinks?.values().next().value
const [floatingLink] = slotFloatingLinks(
network,
'input',
node.id,
node.inputs.indexOf(input)
)
if (floatingLink?.parentId == null) return
try {
@@ -270,42 +276,49 @@ export class LinkConnector {
}
/** Drag all links from an output to a new output. */
moveOutputLink(network: LinkNetwork, output: INodeOutputSlot): void {
moveOutputLink(
network: LinkNetwork,
node: LGraphNode,
output: INodeOutputSlot
): void {
if (this.isConnecting) throw new Error('Already dragging links.')
const { state, renderLinks } = this
// Floating links
if (output._floatingLinks?.size) {
for (const floatingLink of output._floatingLinks.values()) {
try {
const reroute = LLink.getFirstReroute(network, floatingLink)
if (!reroute)
throw new Error(
`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`
)
for (const floatingLink of slotFloatingLinks(
network,
'output',
node.id,
node.outputs.indexOf(output)
)) {
try {
const reroute = LLink.getFirstReroute(network, floatingLink)
if (!reroute)
throw new Error(
`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`
)
const renderLink = new FloatingRenderLink(
network,
floatingLink,
'output',
reroute
)
const mayContinue = this.events.dispatch(
'before-move-output',
renderLink
)
if (mayContinue === false) continue
const renderLink = new FloatingRenderLink(
network,
floatingLink,
'output',
reroute
)
const mayContinue = this.events.dispatch(
'before-move-output',
renderLink
)
if (mayContinue === false) continue
renderLinks.push(renderLink)
this.floatingLinks.push(floatingLink)
} catch (error) {
console.warn(
`Could not create render link for link id: [${floatingLink.id}].`,
floatingLink,
error
)
}
renderLinks.push(renderLink)
this.floatingLinks.push(floatingLink)
} catch (error) {
console.warn(
`Could not create render link for link id: [${floatingLink.id}].`,
floatingLink,
error
)
}
}
@@ -806,20 +819,31 @@ export class LinkConnector {
return
}
// Connecting to output
for (const link of this.renderLinks) {
if (link.toType !== 'output') continue
const result = reroute.findSourceOutput()
if (!result) continue
const { node, output } = result
if (!link.canConnectToOutput(node, output)) continue
// Connecting to output.
const drop = this._resolveOutputDrop(reroute)
if (!drop) return
const { node, output, links } = drop
for (const link of links) {
link.connectToRerouteOutput(reroute, node, output, this.events)
}
}
/** Resolves once: connecting a link re-anchors its chain, changing membership. */
private _resolveOutputDrop(reroute: Reroute) {
const result = reroute.findSourceOutput()
if (!result) return
const { node, output } = result
const links = this.renderLinks.filter(
(link) =>
link.toType === 'output' &&
link.canConnectToReroute(reroute) &&
link.canConnectToOutput(node, output)
)
return { node, output, links }
}
/** @internal Temporary workaround - requires refactor. */
_connectOutputToReroute(reroute: Reroute, renderLink: RenderLinkUnion): void {
const results = reroute.findTargetInputs()
@@ -832,20 +856,9 @@ export class LinkConnector {
// From reroute to reroute
if (renderLink instanceof ToInputRenderLink) {
const { node, fromSlot, fromSlotIndex, fromReroute } = renderLink
const { node, fromSlotIndex } = renderLink
reroute.setFloatingLinkOrigin(node, fromSlot, fromSlotIndex)
// Clean floating link IDs from reroutes about to be removed from the chain
if (fromReroute != null) {
for (const originalReroute of originalReroutes) {
if (originalReroute.id === fromReroute.id) break
for (const linkId of reroute.floatingLinkIds) {
originalReroute.floatingLinkIds.delete(linkId)
}
}
}
reroute.setFloatingLinkOrigin(node, fromSlotIndex)
}
// Filter before any connections are re-created
@@ -1016,16 +1029,7 @@ export class LinkConnector {
}
}
} else {
const result = reroute.findSourceOutput()
if (!result) return false
const { node, output } = result
for (const renderLink of this.renderLinks) {
if (renderLink.toType !== 'output') continue
if (!renderLink.canConnectToReroute(reroute)) continue
if (renderLink.canConnectToOutput(node, output)) return true
}
return !!this._resolveOutputDrop(reroute)?.links.length
}
return false

View File

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

View File

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

View File

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

View File

@@ -65,14 +65,6 @@ export interface LGraphEventMap {
oldValue: unknown
newValue: unknown
}
'node:slot-errors:changed': { nodeId: SerializedNodeId }
'node:slot-links:changed': {
nodeId: SerializedNodeId
slotType: NodeSlotType
slotIndex: number
connected: boolean
linkId: number
}
'node:slot-label:changed': {
nodeId: SerializedNodeId
slotType?: NodeSlotType

View File

@@ -183,6 +183,8 @@ export interface LinkNetwork extends ReadonlyLinkNetwork {
readonly reroutes: Map<RerouteId, Reroute>
addFloatingLink(link: LLink): LLink
removeReroute(id: RerouteId): unknown
/** Removes a reroute from the map and its stores, without chain splicing. */
_removeReroute(id: RerouteId): void
removeFloatingLink(link: LLink): void
}
@@ -329,11 +331,6 @@ export interface INodeSlot extends HasBoundingRect {
pos?: Point
/** @remarks Automatically calculated; not included in serialisation. */
boundingRect: ReadOnlyRect
/**
* A list of floating link IDs that are connected to this slot.
* This is calculated at runtime; it is **not** serialized.
*/
_floatingLinks?: Set<LLink>
/**
* Whether the slot has errors. It is **not** serialized.
*/

View File

@@ -1,5 +1,7 @@
import { registerLinkTopology } from './LLink'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import type { SerializedNodeId } from '@/types/nodeId'
import type { LLink, LinkId } from './LLink'
/** Generates a unique string key for a link's connection tuple. */
@@ -39,20 +41,23 @@ export function selectSurvivorLink(
return ids[0]
}
/** Removes duplicate links from origin outputs and the graph's link map. */
/**
* Removes duplicate links from origin outputs and the graph, routing map
* removal through {@link LGraph._removeLink} so the link and layout stores
* stay in sync.
*/
export function purgeOrphanedLinks(
ids: LinkId[],
keepId: LinkId,
links: Map<LinkId, LLink>,
getNodeById: (id: SerializedNodeId) => LGraphNode | null
graph: LGraph
): void {
for (const id of ids) {
if (id === keepId) continue
const link = links.get(id)
const link = graph._links.get(id)
if (!link) continue
const originNode = getNodeById(link.origin_id)
const originNode = graph.getNodeById(link.origin_id)
const output = originNode?.outputs?.[link.origin_slot]
if (output?.links) {
for (let i = output.links.length - 1; i >= 0; i--) {
@@ -60,8 +65,13 @@ export function purgeOrphanedLinks(
}
}
links.delete(id)
graph._removeLink(id)
}
// Purging a duplicate that owned the survivor's target-slot index entry
// removes that entry, so re-assert the survivor's registration afterwards.
const survivor = graph._links.get(keepId)
if (survivor) registerLinkTopology(graph, survivor)
}
/** Ensures input.link on the target node points to the surviving link. */

View File

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

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from './litegraph'
import type { NodeInputSlot } from './node/NodeInputSlot'
@@ -14,6 +16,8 @@ class TestNode extends LGraphNode {
LiteGraph.registerNodeType('test/TestNode', TestNode)
describe('Serialization - Circular Reference Prevention', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraph.toJSON()', () => {
it('should serialize without circular reference errors', () => {
const graph = new LGraph()

View File

@@ -8,6 +8,7 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
import { toRerouteId } from '@/types/rerouteId'
import {
createTestSubgraph,
@@ -203,5 +204,64 @@ describe('SubgraphConversion', () => {
}
expect(linkRefCount).toBe(4)
})
it('Should truncate cyclic reroute chains instead of aborting unpack', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const inner = createNode(subgraph, [], ['number'])
const innerLink = subgraph.outputNode.slots[0].connect(
inner.outputs[0],
inner
)
assert(innerLink)
const outer = createNode(graph, ['number'])
const outerLink = subgraphNode.connect(0, outer, 0)
assert(outerLink)
const first = subgraph.createReroute([10, 10], innerLink)!
const second = subgraph.createReroute([20, 20], first)!
// Simulate corrupt data: first → second → first
second._chain.parentId = first.id
expect(() => graph.unpackSubgraph(subgraphNode)).not.toThrow()
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(1)
expect(graph.reroutes.size).toBe(2)
const [link] = [...graph.links.values()]
assert(link.parentId !== undefined)
expect(graph.reroutes.get(link.parentId)).toBeDefined()
})
it('Should not stitch broken external parentId references onto merged links', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const inner = createNode(subgraph, [], ['number'])
const innerLink = subgraph.outputNode.slots[0].connect(
inner.outputs[0],
inner
)
assert(innerLink)
const outer = createNode(graph, ['number'])
const outerLink = subgraphNode.connect(0, outer, 0)
assert(outerLink)
// Simulate corrupt data: the external chain names a missing reroute
outerLink.parentId = toRerouteId(999)
graph.unpackSubgraph(subgraphNode)
expect(graph.links.size).toBe(1)
const [link] = [...graph.links.values()]
expect(link.parentId).toBeUndefined()
})
})
})

View File

@@ -1,6 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import { anchorRerouteChain } from '@/lib/litegraph/src/Reroute'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
import type { SubgraphInputEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphInputEventMap'
@@ -114,39 +115,15 @@ export class SubgraphInput extends SubgraphSlot {
)
// Add to graph links list
subgraph._links.set(link.id, link)
subgraph._addLink(link)
// Set link ID in each slot
this.linkIds.push(link.id)
slot.link = link.id
// Reroutes
const reroutes = LLink.getReroutes(subgraph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = subgraph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
subgraph.removeFloatingLink(link)
}
}
}
anchorRerouteChain(subgraph, link)
subgraph.incrementVersion()
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id
})
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
subgraph.afterChange()

View File

@@ -1,7 +1,7 @@
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/types/nodeId'
import { LLink } from '@/lib/litegraph/src/LLink'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
@@ -179,10 +179,14 @@ export class SubgraphInputNode
const { subgraph } = this
// Break floating links
if (input._floatingLinks?.size) {
for (const link of input._floatingLinks) {
subgraph.removeFloatingLink(link)
}
const inputIndex = node.inputs.indexOf(input)
for (const floatingLink of slotFloatingLinks(
subgraph,
'input',
node.id,
inputIndex
)) {
subgraph.removeFloatingLink(floatingLink)
}
input.link = null
@@ -222,22 +226,14 @@ export class SubgraphInputNode
input: subgraphInput
})
const slotIndex = node.inputs.findIndex((inp) => inp === input)
if (slotIndex !== -1) {
if (inputIndex !== -1) {
node.onConnectionsChange?.(
NodeSlotType.INPUT,
slotIndex,
inputIndex,
false,
link,
subgraphInput
)
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: slotIndex,
connected: false,
linkId: link.id
})
}
}

View File

@@ -32,6 +32,7 @@ import type {
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
@@ -643,20 +644,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
const store = useWidgetValueStore()
input.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled,
isDOMWidget:
'isDOMWidget' in interiorWidget &&
typeof interiorWidget.isDOMWidget === 'boolean'
? interiorWidget.isDOMWidget
: undefined
})
store.registerWidget(
id,
{
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled
},
deriveWidgetRenderState(interiorWidget)
)
input._widget =
this.createPromotedHostWidget(input, id, interiorWidget) ??
this._projectPromotedWidget(input)

View File

@@ -3,6 +3,7 @@ import { pull } from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import { anchorRerouteChain } from '@/lib/litegraph/src/Reroute'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type {
INodeInputSlot,
@@ -78,31 +79,14 @@ export class SubgraphOutput extends SubgraphSlot {
)
// Add to graph links list
subgraph._links.set(link.id, link)
subgraph._addLink(link)
// Set link ID in each slot
this.linkIds[0] = link.id
slot.links ??= []
slot.links.push(link.id)
// Reroutes
const reroutes = LLink.getReroutes(subgraph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = subgraph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
subgraph.removeFloatingLink(link)
}
}
}
anchorRerouteChain(subgraph, link)
subgraph.incrementVersion()
node.onConnectionsChange?.(

View File

@@ -500,6 +500,36 @@ describe('SubgraphSerialization - Data Integrity', () => {
}
})
it('serializes interior links with contract key order and round-trips byte-identically', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
const nodeA = createRegisteredNode(subgraph, [], ['number'], 'A')
const nodeB = createRegisteredNode(subgraph, ['number'], ['string'], 'B')
const nodeC = createRegisteredNode(subgraph, ['string'], [], 'C')
nodeA.connect(0, nodeB, 0)
nodeB.connect(0, nodeC, 0)
const first = subgraph.asSerialisable()
expect(first.links?.length).toBe(2)
for (const link of first.links ?? []) {
expect(Object.keys(link)).toEqual([
'id',
'origin_id',
'origin_slot',
'target_id',
'target_slot',
'type'
])
}
const restored = new Subgraph(new LGraph(), first)
restored.configure(first)
const second = restored.asSerialisable()
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
it('deduplicates duplicate subgraph node IDs while keeping root nodes canonical', () => {
const graph = new LGraph()
graph.configure(structuredClone(duplicateSubgraphNodeIds))

View File

@@ -154,7 +154,11 @@ describe('Subgraph slot connections', () => {
const connector = new LinkConnector(setConnectingLinks)
// Now try to drag from the input slot
connector.moveInputLink(subgraph as LinkNetwork, internalNode.inputs[0])
connector.moveInputLink(
subgraph as LinkNetwork,
internalNode,
internalNode.inputs[0]
)
// Verify that we're dragging the existing link
expect(connector.isConnecting).toBe(true)

View File

@@ -4,9 +4,20 @@ import {
} from '@/lib/litegraph/src/constants'
import { describe, expect, it } from 'vitest'
import type { ExportedSubgraph } from '../types/serialisation'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { topologicalSortSubgraphs } from './subgraphDeduplication'
import type { LGraphState } from '../LGraph'
import type {
ExportedSubgraph,
SerialisableLLink,
SerialisableReroute
} from '../types/serialisation'
import {
deduplicateSubgraphRerouteIds,
topologicalSortSubgraphs
} from './subgraphDeduplication'
function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
return {
@@ -78,3 +89,100 @@ describe('topologicalSortSubgraphs', () => {
expect(topologicalSortSubgraphs([])).toEqual([])
})
})
function reroute(
id: number,
parentId?: number,
linkIds: number[] = []
): SerialisableReroute {
return { id, parentId, pos: [0, 0], linkIds }
}
function chainedLink(id: number, parentId?: number): SerialisableLLink {
return {
id: toLinkId(id),
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: 'INT',
parentId: parentId === undefined ? undefined : toRerouteId(parentId)
}
}
function freshState(lastRerouteId = 0): LGraphState {
return {
lastGroupId: 0,
lastNodeId: 0,
lastLinkId: toLinkId(0),
lastRerouteId: toRerouteId(lastRerouteId)
}
}
describe('deduplicateSubgraphRerouteIds', () => {
it('remaps colliding reroute ids and patches parentId references', () => {
const subgraph = makeSubgraph('sg')
subgraph.reroutes = [reroute(1, undefined, [1]), reroute(2, 1, [1])]
subgraph.links = [chainedLink(1, 2)]
const state = freshState(1)
deduplicateSubgraphRerouteIds([subgraph], new Set([1]), state)
const [first, second] = subgraph.reroutes
expect(first.id).not.toBe(1)
expect(second.parentId).toBe(first.id)
expect(subgraph.links[0].parentId).toBe(second.id)
expect(Number(state.lastRerouteId)).toBeGreaterThanOrEqual(first.id)
})
it('remaps chained collisions created by the remap itself', () => {
const subgraph = makeSubgraph('sg')
subgraph.reroutes = [reroute(1), reroute(2, 1)]
subgraph.links = [chainedLink(1, 2)]
const state = freshState(1)
deduplicateSubgraphRerouteIds([subgraph], new Set([1, 2]), state)
const ids = subgraph.reroutes.map((r) => r.id)
expect(new Set(ids).size).toBe(2)
expect(ids).not.toContain(1)
expect(ids).not.toContain(2)
expect(subgraph.reroutes[1].parentId).toBe(subgraph.reroutes[0].id)
expect(subgraph.links[0].parentId).toBe(subgraph.reroutes[1].id)
})
it('keeps sibling subgraphs from colliding with each other', () => {
const first = makeSubgraph('first')
first.reroutes = [reroute(1)]
const second = makeSubgraph('second')
second.reroutes = [reroute(1)]
const state = freshState(0)
deduplicateSubgraphRerouteIds([first, second], new Set(), state)
expect(first.reroutes[0].id).toBe(1)
expect(second.reroutes[0].id).not.toBe(1)
})
it('patches floating link parentId references', () => {
const subgraph = makeSubgraph('sg')
subgraph.reroutes = [reroute(1)]
subgraph.floatingLinks = [chainedLink(1, 1)]
const state = freshState(1)
deduplicateSubgraphRerouteIds([subgraph], new Set([1]), state)
expect(subgraph.floatingLinks[0].parentId).toBe(subgraph.reroutes[0].id)
})
it('reserves non-colliding ids and advances the shared counter', () => {
const subgraph = makeSubgraph('sg')
subgraph.reroutes = [reroute(7)]
const state = freshState(0)
deduplicateSubgraphRerouteIds([subgraph], new Set(), state)
expect(subgraph.reroutes[0].id).toBe(7)
expect(Number(state.lastRerouteId)).toBe(7)
})
})

View File

@@ -1,6 +1,7 @@
import type { LGraphState } from '../LGraph'
import { toNodeId } from '@/types/nodeId'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import type {
ExportedSubgraph,
ExposedWidget,
@@ -8,7 +9,7 @@ import type {
SerialisableLLink
} from '../types/serialisation'
const MAX_NODE_ID = 100_000_000
const MAX_ID = 100_000_000
interface DeduplicationResult {
subgraphs: ExportedSubgraph[]
@@ -81,7 +82,7 @@ function remapNodeIds(
const numericId = numericSerializedNodeId(id)
if (usedNodeIdKeys.has(key)) {
const newId = findNextAvailableId(usedNodeIds, state)
const newId = findNextAvailableId(usedNodeIds, () => ++state.lastNodeId)
remappedIds.set(key, newId)
node.id = newId
usedNodeIds.add(newId)
@@ -101,10 +102,7 @@ function remapNodeIds(
return remappedIds
}
/**
* Finds the next unused node ID by incrementing `state.lastNodeId`.
* Throws if the ID space is exhausted.
*/
/** Parses a serialized node ID as an integer, or `null` when non-numeric. */
function numericSerializedNodeId(id: SerializedNodeId): number | null {
const key = toNodeId(id)
const numericId = Number(key)
@@ -113,17 +111,20 @@ function numericSerializedNodeId(id: SerializedNodeId): number | null {
: null
}
/**
* Finds the next unused ID by repeatedly calling `advance`.
* Throws if the ID space is exhausted.
*/
function findNextAvailableId(
usedNodeIds: Set<number>,
state: LGraphState
usedIds: Set<number>,
advance: () => number
): number {
while (true) {
const nextId = state.lastNodeId + 1
if (nextId > MAX_NODE_ID) {
const nextId = advance()
if (nextId > MAX_ID) {
throw new Error('Node ID space exhausted')
}
state.lastNodeId = nextId
if (!usedNodeIds.has(nextId)) return nextId
if (!usedIds.has(nextId)) return nextId
}
}
@@ -152,6 +153,87 @@ function patchPromotedWidgets(
}
}
/**
* Dedupes reroute IDs across serialized subgraph definitions. Reroute IDs
* must be unique within a root graph: the reroute store keys every reroute
* in a root graph (its subgraphs' included) in one bucket, but subgraph
* definitions from older frontends or external tools may number their
* reroutes from scratch. Remaps colliding IDs in place and patches every
* reference within the subgraph (`reroute.parentId`, `link.parentId`),
* advancing `state.lastRerouteId`.
*/
export function deduplicateSubgraphRerouteIds(
subgraphs: ExportedSubgraph[],
reservedRerouteIds: Set<number>,
state: LGraphState
): void {
const usedRerouteIds = new Set(reservedRerouteIds)
for (const id of reservedRerouteIds) reserveRerouteId(id, state)
for (const subgraph of subgraphs) {
const remapped = remapRerouteIds(subgraph, usedRerouteIds, state)
if (remapped.size > 0) patchRerouteReferences(subgraph, remapped)
}
}
/** Advances `state.lastRerouteId` so future allocations skip `id`. */
function reserveRerouteId(id: number, state: LGraphState): void {
if (id > state.lastRerouteId) state.lastRerouteId = toRerouteId(id)
}
/**
* Remaps duplicate reroute IDs to unique values, updating `usedRerouteIds`
* and `state.lastRerouteId` as new IDs are allocated.
* @returns A map of old ID → new ID for reroutes that were remapped.
*/
function remapRerouteIds(
subgraph: ExportedSubgraph,
usedRerouteIds: Set<number>,
state: LGraphState
): Map<number, number> {
const remapped = new Map<number, number>()
for (const reroute of subgraph.reroutes ?? []) {
if (usedRerouteIds.has(reroute.id)) {
const newId = findNextAvailableId(usedRerouteIds, () => {
state.lastRerouteId = toRerouteId(state.lastRerouteId + 1)
return state.lastRerouteId
})
remapped.set(reroute.id, newId)
console.warn(
`LiteGraph: duplicate subgraph reroute ID ${reroute.id} remapped to ${newId}`
)
reroute.id = newId
usedRerouteIds.add(newId)
} else {
usedRerouteIds.add(reroute.id)
reserveRerouteId(reroute.id, state)
}
}
return remapped
}
/** Patches every reference to a remapped reroute ID within a subgraph. */
function patchRerouteReferences(
subgraph: ExportedSubgraph,
remapped: Map<number, number>
): void {
for (const reroute of subgraph.reroutes ?? []) {
if (reroute.parentId === undefined) continue
const newParentId = remapped.get(reroute.parentId)
if (newParentId !== undefined) reroute.parentId = newParentId
}
for (const link of [
...(subgraph.links ?? []),
...(subgraph.floatingLinks ?? [])
]) {
if (link.parentId === undefined) continue
const newParentId = remapped.get(link.parentId)
if (newParentId !== undefined) link.parentId = toRerouteId(newParentId)
}
}
/**
* Topologically sorts subgraph definitions so that leaf subgraphs (those
* that no other subgraph depends on) are configured first. This ensures

View File

@@ -2,7 +2,7 @@ import { isEqual } from 'es-toolkit'
import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
@@ -114,8 +114,10 @@ export function getBoundaryLinks(
// Inputs
if (node.inputs) {
for (const input of node.inputs) {
addFloatingLinks(input._floatingLinks)
for (const [inputIndex, input] of node.inputs.entries()) {
addFloatingLinks(
slotFloatingLinks(graph, 'input', node.id, inputIndex)
)
if (input.link == null) continue
@@ -142,8 +144,10 @@ export function getBoundaryLinks(
// Outputs
if (node.outputs) {
for (const output of node.outputs) {
addFloatingLinks(output._floatingLinks)
for (const [outputIndex, output] of node.outputs.entries()) {
addFloatingLinks(
slotFloatingLinks(graph, 'output', node.id, outputIndex)
)
if (!output.links) continue
@@ -204,9 +208,7 @@ export function getBoundaryLinks(
* Adds any floating links that cross the boundary.
* @param floatingLinks The floating links to check
*/
function addFloatingLinks(floatingLinks: Set<LLink> | undefined): void {
if (!floatingLinks) return
function addFloatingLinks(floatingLinks: LLink[]): void {
for (const link of floatingLinks) {
const crossesBoundary = LLink.getReroutes(graph, link).some(
(reroute) => !items.has(reroute)

View File

@@ -9,20 +9,6 @@ interface NodePropertyChangedEvent {
newValue: unknown
}
interface NodeSlotErrorsChangedEvent {
type: 'node:slot-errors:changed'
nodeId: SerializedNodeId
}
interface NodeSlotLinksChangedEvent {
type: 'node:slot-links:changed'
nodeId: SerializedNodeId
slotType: NodeSlotType
slotIndex: number
connected: boolean
linkId: number
}
interface NodeSlotLabelChangedEvent {
type: 'node:slot-label:changed'
nodeId: SerializedNodeId
@@ -31,14 +17,10 @@ interface NodeSlotLabelChangedEvent {
export type LGraphTriggerEvent =
| NodePropertyChangedEvent
| NodeSlotErrorsChangedEvent
| NodeSlotLinksChangedEvent
| NodeSlotLabelChangedEvent
export const LGraphTriggerActions = [
'node:property:changed',
'node:slot-errors:changed',
'node:slot-links:changed',
'node:slot-label:changed'
] as const satisfies readonly LGraphTriggerEvent['type'][]

View File

@@ -68,14 +68,14 @@ export interface SerialisableGraph extends BaseExportedGraph {
export type ISerialisableNodeInput = Omit<
INodeInputSlot,
'boundingRect' | 'widget' | 'link' | '_floatingLinks'
'boundingRect' | 'widget' | 'link'
> & {
link?: number | null
widget?: { name: string }
}
export type ISerialisableNodeOutput = Omit<
INodeOutputSlot,
'boundingRect' | '_data' | 'links' | '_floatingLinks'
'boundingRect' | '_data' | 'links'
> & {
links?: number[] | null
widget?: { name: string }
@@ -164,7 +164,7 @@ export interface ExportedSubgraph extends SerialisableGraph {
/** Properties shared by subgraph and node I/O slots. */
type SubgraphIOShared = Omit<
INodeSlot,
'boundingRect' | 'nameLocked' | 'locked' | 'removable' | '_floatingLinks'
'boundingRect' | 'nameLocked' | 'locked' | 'removable'
>
/** Subgraph I/O slots */

View File

@@ -1,5 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { WidgetRenderState } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import type { UUID } from '@/utils/uuid'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
@@ -24,6 +29,35 @@ export function evaluateInput(input: string): number | undefined {
return newValue
}
export function getWidgetIds(
widgets: readonly { readonly widgetId?: WidgetId }[]
): WidgetId[] {
return widgets
.map((widget) => widget.widgetId)
.filter((id): id is WidgetId => id !== undefined)
}
function isDOMBackedWidget(widget: Readonly<IBaseWidget>): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
export function deriveWidgetRenderState(
widget: Readonly<IBaseWidget>
): WidgetRenderState {
return {
advanced: widget.options?.advanced ?? widget.advanced,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(widget),
tooltip: widget.tooltip
}
}
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>
): UUID | undefined

View File

@@ -4,7 +4,15 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
INumericWidget
} from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import type {
DrawWidgetOptions,
WidgetEventOptions
} from '@/lib/litegraph/src/widgets/BaseWidget'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toNodeId } from '@/types/nodeId'
@@ -27,6 +35,28 @@ function createTestWidget(
)
}
class MutableTypeWidget extends BaseWidget<IBaseWidget<number, string>> {
drawWidget(
_ctx: CanvasRenderingContext2D,
_options: DrawWidgetOptions
): void {}
onClick(_options: WidgetEventOptions): void {}
}
function createMutableTypeWidget(node: LGraphNode): MutableTypeWidget {
return new MutableTypeWidget(
{
type: 'number',
name: 'typeChangedWidget',
value: 42,
options: { min: 0, max: 100 },
y: 0
},
node
)
}
describe('BaseWidget store integration', () => {
let graph: LGraph
let node: LGraphNode
@@ -175,6 +205,31 @@ describe('BaseWidget store integration', () => {
store.getWidget(widgetId(graph.id, toNodeId(1), 'valuesWidget'))?.value
).toBe(77)
})
it('registers the live widget type', () => {
const widget = createMutableTypeWidget(node)
widget.type = 'number-custom'
widget.setNodeId(toNodeId(1))
expect(
store.getWidget(widgetId(graph.id, toNodeId(1), 'typeChangedWidget'))
?.type
).toBe('number-custom')
})
it('stores explicit isDOMWidget false over component presence', () => {
const widget = createTestWidget(node, { name: 'flaggedDomWidget' })
Object.assign(widget, { component: {}, isDOMWidget: false })
widget.setNodeId(toNodeId(1))
expect(
store.getWidgetRenderState(
widgetId(graph.id, toNodeId(1), 'flaggedDomWidget')
)?.isDOMWidget
).toBe(false)
})
})
describe('DOM widget value registration', () => {

View File

@@ -17,6 +17,7 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
@@ -147,12 +148,16 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._state = useWidgetValueStore().registerWidget(
widgetId(graphId, nodeId, this.name),
const store = useWidgetValueStore()
const id = widgetId(graphId, nodeId, this.name)
this._state = store.registerWidget(
id,
{
...this._state,
type: this.type,
value: this.value
}
},
deriveWidgetRenderState(this)
)
}

View File

@@ -124,6 +124,25 @@ describe('useCanvasStore', () => {
})
})
describe('rootGraphId', () => {
it('tracks the graph id reassigned by a workflow load', async () => {
const graph = new LGraph()
const fakeCanvas = {
canvas: document.createElement('canvas'),
graph,
selectedItems: new Set()
}
store.canvas = fakeCanvas as unknown as LGraphCanvas
await nextTick()
expect(store.rootGraphId).toBe(graph.id)
const workflowId = '11111111-1111-4111-8111-111111111111'
graph.configure({ ...graph.serialize(), id: workflowId })
expect(store.rootGraphId).toBe(workflowId)
})
})
it('Does not include groups in selected nodeIds', async () => {
store.selectedItems = [new LGraphGroup()]

View File

@@ -115,6 +115,7 @@ export const useCanvasStore = defineStore('canvas', () => {
}
const currentGraph = shallowRef<LGraph | null>(null)
const rootGraphId = computed(() => currentGraph.value?.rootGraph.id)
const isInSubgraph = ref(false)
const isGhostPlacing = ref(false)
@@ -190,6 +191,7 @@ export const useCanvasStore = defineStore('canvas', () => {
initScaleSync,
cleanupScaleSync,
currentGraph,
rootGraphId,
isInSubgraph,
isGhostPlacing
}

View File

@@ -54,7 +54,7 @@ export class LinkConnectorAdapter {
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
if (opts?.moveExisting) {
this.linkConnector.moveOutputLink(this.network, output)
this.linkConnector.moveOutputLink(this.network, node, output)
} else {
this.linkConnector.dragNewFromOutput(
this.network,
@@ -90,7 +90,9 @@ export class LinkConnectorAdapter {
const startPoint: Point | undefined = opts.layout
? [opts.layout.position.x, opts.layout.position.y]
: undefined
this.linkConnector.moveInputLink(this.network, input, { startPoint })
this.linkConnector.moveInputLink(this.network, node, input, {
startPoint
})
} else {
this.linkConnector.dragNewFromInput(
this.network,

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -11,6 +13,7 @@ const MISSING_NODE = toNodeId('999')
const NEW_NODE = toNodeId('99')
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
layoutStore.initializeFromLiteGraph([
{ id: NODE_1, pos: [10, 20], size: [200, 100] },
{ id: NODE_2, pos: [300, 400], size: [150, 80] }

View File

@@ -10,7 +10,6 @@ import type { NodeId } from '@/types/nodeId'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type {
LayoutSource,
LinkId,
NodeLayout,
Point,
RerouteId,
@@ -26,22 +25,9 @@ interface LayoutMutations {
setNodeZIndex(nodeId: NodeId, zIndex: number): void
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
deleteNode(nodeId: NodeId): void
createLink(
linkId: LinkId,
sourceNodeId: NodeId,
sourceSlot: number,
targetNodeId: NodeId,
targetSlot: number
): void
deleteLink(linkId: LinkId): void
// Reroute operations
createReroute(
rerouteId: RerouteId,
position: Point,
parentId?: RerouteId,
linkIds?: LinkId[]
): void
createReroute(rerouteId: RerouteId, position: Point): void
deleteReroute(rerouteId: RerouteId): void
moveReroute(
rerouteId: RerouteId,
@@ -217,72 +203,16 @@ export function useLayoutMutations(): LayoutMutations {
setNodeZIndex(nodeId, maxZIndex + 1)
}
/**
* Create a new link
*/
const createLink = (
linkId: LinkId,
sourceNodeId: NodeId,
sourceSlot: number,
targetNodeId: NodeId,
targetSlot: number
): void => {
logger.debug('Creating link:', {
linkId,
from: `${sourceNodeId}[${sourceSlot}]`,
to: `${targetNodeId}[${targetSlot}]`
})
layoutStore.applyOperation({
type: 'createLink',
entity: 'link',
linkId,
sourceNodeId,
sourceSlot,
targetNodeId,
targetSlot,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a link
*/
const deleteLink = (linkId: LinkId): void => {
logger.debug('Deleting link:', linkId)
layoutStore.applyOperation({
type: 'deleteLink',
entity: 'link',
linkId,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Create a new reroute
*/
const createReroute = (
rerouteId: RerouteId,
position: Point,
parentId?: RerouteId,
linkIds: LinkId[] = []
): void => {
logger.debug('Creating reroute:', {
rerouteId,
position,
parentId,
linkCount: linkIds.length
})
const createReroute = (rerouteId: RerouteId, position: Point): void => {
logger.debug('Creating reroute:', { rerouteId, position })
layoutStore.applyOperation({
type: 'createReroute',
entity: 'reroute',
rerouteId,
position,
parentId,
linkIds,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
@@ -339,8 +269,6 @@ export function useLayoutMutations(): LayoutMutations {
createNode,
deleteNode,
bringNodeToFront,
createLink,
deleteLink,
createReroute,
deleteReroute,
moveReroute

View File

@@ -1,3 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toLinkId } from '@/types/linkId'
@@ -29,6 +32,10 @@ function expectSingleOperation(
expect(operations[0]).toEqual(expect.objectContaining(expectedOperation))
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
// Clear the store before each test
@@ -771,7 +778,7 @@ describe('layoutStore getNodeLayoutRef setter', () => {
}
)
it('emits a deleteNode operation when setter receives null', () => {
it('ignores a null assignment; deletion goes through layoutMutations.deleteNode', () => {
const ref = layoutStore.getNodeLayoutRef(REF_NODE)
const layout = baseLayout()
ref.value = layout
@@ -780,12 +787,8 @@ describe('layoutStore getNodeLayoutRef setter', () => {
ref.value = null
})
expectSingleOperation(operations, {
type: 'deleteNode',
nodeId: REF_NODE,
previousLayout: layout
})
expect(ref.value).toBeNull()
expect(operations).toEqual([])
expect(ref.value).toEqual(layout)
})
})
@@ -852,7 +855,7 @@ describe('layoutStore link layout updates', () => {
layoutStore.initializeFromLiteGraph([])
})
const stubPath = () => ({}) as unknown as Path2D
const stubPath = () => fromPartial<Path2D>({})
const baseLink = (path = stubPath()) => ({
id: toLinkId(1),
path,

View File

@@ -10,7 +10,6 @@ import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
@@ -19,10 +18,8 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import type {
BatchUpdateBoundsOperation,
Bounds,
CreateLinkOperation,
CreateNodeOperation,
CreateRerouteOperation,
DeleteLinkOperation,
DeleteNodeOperation,
DeleteRerouteOperation,
LayoutChange,
@@ -73,23 +70,9 @@ function asRerouteId(id: string | number): RerouteId {
return toRerouteId(Number(id))
}
function asLinkId(id: string | number): LinkId {
return toLinkId(Number(id))
}
interface LinkData {
id: LinkId
sourceNodeId: NodeId
targetNodeId: NodeId
sourceSlot: number
targetSlot: number
}
interface RerouteData {
id: RerouteId
position: Point
parentId: RerouteId
linkIds: LinkId[]
}
// Generic typed Y.Map interface
@@ -101,15 +84,12 @@ interface TypedYMap<T> {
class LayoutStoreImpl implements LayoutStore {
private static readonly REROUTE_DEFAULTS: RerouteData = {
id: toRerouteId(0),
position: { x: 0, y: 0 },
parentId: toRerouteId(0),
linkIds: []
position: { x: 0, y: 0 }
}
// Yjs document and shared data structures
private ydoc = new Y.Doc()
private ynodes: Y.Map<NodeLayoutMap> // Maps nodeId -> NodeLayoutMap containing NodeLayout data
private ylinks: Y.Map<Y.Map<unknown>> // Maps linkId -> Y.Map containing link data
private yreroutes: Y.Map<Y.Map<unknown>> // Maps rerouteId -> Y.Map containing reroute data
private yoperations: Y.Array<LayoutOperation> // Operation log
@@ -172,7 +152,6 @@ class LayoutStoreImpl implements LayoutStore {
constructor() {
// Initialize Yjs data structures
this.ynodes = this.ydoc.getMap('nodes')
this.ylinks = this.ydoc.getMap('links')
this.yreroutes = this.ydoc.getMap('reroutes')
this.yoperations = this.ydoc.getArray('operations')
@@ -195,14 +174,6 @@ class LayoutStoreImpl implements LayoutStore {
})
})
// Listen for link changes and update spatial indexes
this.ylinks.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
this.version++
event.changes.keys.forEach((change, linkIdStr) => {
this.handleLinkChange(change, linkIdStr)
})
})
// Listen for reroute changes and update spatial indexes
this.yreroutes.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
this.version++
@@ -212,14 +183,6 @@ class LayoutStoreImpl implements LayoutStore {
})
}
private getLinkField<K extends keyof LinkData>(
ylink: Y.Map<unknown>,
field: K
): LinkData[K] | undefined {
const typedLink = ylink as TypedYMap<LinkData>
return typedLink.get(field)
}
private getRerouteField<K extends keyof RerouteData>(
yreroute: Y.Map<unknown>,
field: K,
@@ -249,80 +212,68 @@ class LayoutStoreImpl implements LayoutStore {
return layout
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
// Delete operation
const existing = this.ynodes.get(nodeKey)
if (existing) {
this.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor,
previousLayout: yNodeToLayout(existing)
})
}
// No caller assigns null through this ref; deletion goes through
// layoutMutations.deleteNode, which carries a graphId.
if (newLayout === null) return
// Update operation - detect what changed
const existing = this.ynodes.get(nodeKey)
if (!existing) {
// Create operation
this.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout: newLayout,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
} else {
// Update operation - detect what changed
const existing = this.ynodes.get(nodeKey)
if (!existing) {
// Create operation
const existingLayout = yNodeToLayout(existing)
// Check what properties changed
if (
existingLayout.position.x !== newLayout.position.x ||
existingLayout.position.y !== newLayout.position.y
) {
this.applyOperation({
type: 'createNode',
type: 'moveNode',
entity: 'node',
nodeId,
layout: newLayout,
position: newLayout.position,
previousPosition: existingLayout.position,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (
existingLayout.size.width !== newLayout.size.width ||
existingLayout.size.height !== newLayout.size.height
) {
this.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newLayout.size,
previousSize: existingLayout.size,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (existingLayout.zIndex !== newLayout.zIndex) {
this.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex: newLayout.zIndex,
previousZIndex: existingLayout.zIndex,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
} else {
const existingLayout = yNodeToLayout(existing)
// Check what properties changed
if (
existingLayout.position.x !== newLayout.position.x ||
existingLayout.position.y !== newLayout.position.y
) {
this.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newLayout.position,
previousPosition: existingLayout.position,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (
existingLayout.size.width !== newLayout.size.width ||
existingLayout.size.height !== newLayout.size.height
) {
this.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newLayout.size,
previousSize: existingLayout.size,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (existingLayout.zIndex !== newLayout.zIndex) {
this.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex: newLayout.zIndex,
previousZIndex: existingLayout.zIndex,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
}
}
trigger()
@@ -862,12 +813,6 @@ class LayoutStoreImpl implements LayoutStore {
case 'batchUpdateBounds':
this.handleBatchUpdateBounds(operation, change)
break
case 'createLink':
this.handleCreateLink(operation, change)
break
case 'deleteLink':
this.handleDeleteLink(operation, change)
break
case 'createReroute':
this.handleCreateReroute(operation, change)
break
@@ -1123,18 +1068,8 @@ class LayoutStoreImpl implements LayoutStore {
// and cleanup is handled by onUnmounted in useSlotElementTracking.
// Remove from spatial index
this.spatialIndex.remove(nodeId)
// Clean up associated links
const linksToDelete = this.findLinksConnectedToNode(nodeId)
// Delete the associated links
for (const linkId of linksToDelete) {
const linkKey = String(linkId)
this.ylinks.delete(linkKey)
this.linkLayouts.delete(linkId)
// Clean up link segment layouts
this.cleanupLinkSegments(linkId)
}
// Link geometry is cleaned up per-link by LLink.disconnect as the node's
// connections are severed, so nothing to do here.
change.type = 'delete'
change.nodeIds.push(nodeId)
@@ -1172,40 +1107,6 @@ class LayoutStoreImpl implements LayoutStore {
}
}
private handleCreateLink(
operation: CreateLinkOperation,
change: LayoutChange
): void {
const linkData = new Y.Map<unknown>()
linkData.set('id', operation.linkId)
linkData.set('sourceNodeId', operation.sourceNodeId)
linkData.set('sourceSlot', operation.sourceSlot)
linkData.set('targetNodeId', operation.targetNodeId)
linkData.set('targetSlot', operation.targetSlot)
const linkKey = String(operation.linkId)
this.ylinks.set(linkKey, linkData)
// Link geometry will be computed separately when nodes move
// This just tracks that the link exists
change.type = 'create'
}
private handleDeleteLink(
operation: DeleteLinkOperation,
change: LayoutChange
): void {
const linkKey = String(operation.linkId)
if (!this.ylinks.has(linkKey)) return
this.ylinks.delete(linkKey)
this.linkLayouts.delete(operation.linkId)
// Clean up any segment layouts for this link
this.cleanupLinkSegments(operation.linkId)
change.type = 'delete'
}
private handleCreateReroute(
operation: CreateRerouteOperation,
change: LayoutChange
@@ -1213,8 +1114,6 @@ class LayoutStoreImpl implements LayoutStore {
const rerouteData = new Y.Map<unknown>()
rerouteData.set('id', operation.rerouteId)
rerouteData.set('position', operation.position)
rerouteData.set('parentId', operation.parentId)
rerouteData.set('linkIds', operation.linkIds)
const rerouteKey = String(operation.rerouteId)
this.yreroutes.set(rerouteKey, rerouteData)
@@ -1281,43 +1180,6 @@ class LayoutStoreImpl implements LayoutStore {
})
}
/**
* Find all links connected to a specific node
*/
private findLinksConnectedToNode(nodeId: NodeId): LinkId[] {
const connectedLinks: LinkId[] = []
this.ylinks.forEach((linkData: Y.Map<unknown>, linkIdStr: string) => {
const linkId = asLinkId(linkIdStr)
const sourceNodeId = this.getLinkField(linkData, 'sourceNodeId')
const targetNodeId = this.getLinkField(linkData, 'targetNodeId')
if (sourceNodeId === nodeId || targetNodeId === nodeId) {
connectedLinks.push(linkId)
}
})
return connectedLinks
}
/**
* Handle link change events
*/
private handleLinkChange(change: YEventChange, linkIdStr: string): void {
if (change.action === 'delete') {
const linkId = asLinkId(linkIdStr)
this.cleanupLinkData(linkId)
}
// Link was added or updated - geometry will be computed separately
// This just tracks that the link exists in CRDT
}
/**
* Clean up all data associated with a link
*/
private cleanupLinkData(linkId: LinkId): void {
this.linkLayouts.delete(linkId)
this.cleanupLinkSegments(linkId)
}
/**
* Clean up all segment layouts for a link
*/

View File

@@ -113,7 +113,6 @@ interface OperationMeta {
* Entity-specific base types for proper type discrimination
*/
type NodeOpBase = OperationMeta & { entity: 'node'; nodeId: NodeId }
type LinkOpBase = OperationMeta & { entity: 'link'; linkId: LinkId }
type RerouteOpBase = OperationMeta & {
entity: 'reroute'
rerouteId: RerouteId
@@ -130,8 +129,6 @@ type OperationType =
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdateBounds'
| 'createLink'
| 'deleteLink'
| 'createReroute'
| 'deleteReroute'
| 'moveReroute'
@@ -198,32 +195,12 @@ export interface BatchUpdateBoundsOperation extends OperationMeta {
bounds: Record<NodeId, { bounds: Bounds; previousBounds: Bounds }>
}
/**
* Create link operation
*/
export interface CreateLinkOperation extends LinkOpBase {
type: 'createLink'
sourceNodeId: NodeId
sourceSlot: number
targetNodeId: NodeId
targetSlot: number
}
/**
* Delete link operation
*/
export interface DeleteLinkOperation extends LinkOpBase {
type: 'deleteLink'
}
/**
* Create reroute operation
*/
export interface CreateRerouteOperation extends RerouteOpBase {
type: 'createReroute'
position: Point
parentId?: RerouteId
linkIds: LinkId[]
}
/**
@@ -253,8 +230,6 @@ export type LayoutOperation =
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateBoundsOperation
| CreateLinkOperation
| DeleteLinkOperation
| CreateRerouteOperation
| DeleteRerouteOperation
| MoveRerouteOperation

View File

@@ -85,7 +85,6 @@ describe('Vue Node - Subgraph Functionality', () => {
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,

View File

@@ -60,7 +60,7 @@ vi.mock(
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { getNodeById: vi.fn() },
rootGraph: { id: 'graph-test', getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
@@ -161,7 +161,6 @@ const mockNodeData: VueNodeData = {
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
@@ -178,6 +177,7 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockExecuting = false
mockData.mockLgraphNode = null
setActivePinia(pinia)
const canvasStore = useCanvasStore()
@@ -274,17 +274,16 @@ describe('LGraphNode', () => {
})
it('should hide advanced footer button while the node is collapsed', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
flags: { collapsed: true }
}
})
@@ -294,18 +293,17 @@ describe('LGraphNode', () => {
})
it('should show error-only footer for collapsed nodes with advanced widgets', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
hasErrors: true,
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
hasErrors: true
}
})

View File

@@ -60,7 +60,7 @@
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
hasAnyError ? '-inset-1.75' : '-inset-0.75',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'
@@ -101,10 +101,10 @@
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
<NodeSlots :node-data unified />
</template>
<NodeHeader
:node-data="nodeData"
:node-data
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@@ -124,7 +124,7 @@
/>
<template v-if="!isCollapsed && isRerouteNode">
<NodeSlots :node-data="nodeData" />
<NodeSlots :node-data />
</template>
<template v-else-if="!isCollapsed">
@@ -151,20 +151,16 @@
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
<NodeSlots :node-data />
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<NodeWidgets v-if="hasRenderableWidgets" :node-data />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent v-if="nodeMedia" :node-data :media="nodeMedia" />
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
:node-data="nodeData"
:node-data
:media="preview"
/>
</div>
@@ -297,6 +293,10 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isVideoOutput } from '@/utils/litegraphUtil'
import {
@@ -719,18 +719,30 @@ const { promotedPreviews } = usePromotedPreviews(lgraphNode)
useGLSLPreview(lgraphNode)
const widgetValueStore = useWidgetValueStore()
const widgetIds = computed(() => {
const graphId = app.rootGraph?.id
const bareNodeId = stripGraphPrefix(nodeData.id)
if (!graphId || !bareNodeId) return []
return widgetValueStore.getNodeWidgetIds(graphId, bareNodeId) ?? []
})
const hasRenderableWidgets = computed(() => widgetIds.value.length > 0)
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false
if (isCollapsed.value) return false
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
return hasUnpromotedWidgets(node)
}
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
const hasAdvancedWidgets = widgetIds.value.some((id) => {
const renderState = widgetValueStore.getWidgetRenderState(id)
const widgetState = widgetValueStore.getWidget(id)
return renderState?.advanced ?? widgetState?.options?.advanced
})
const alwaysShowAdvanced = settingStore.get(
'Comfy.Node.AlwaysShowAdvancedWidgets'
)

View File

@@ -1,6 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { computed } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { fromPartial } from '@total-typescript/shoehorn'
@@ -9,12 +12,20 @@ vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ inputIsWidget: () => true })
}))
// Serializes the nodeData prop so tests can assert on the data contract
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
const NodeWidgetsProbe = {
props: ['nodeData'],
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
const WidgetGridProbe = {
props: ['processedWidgets'],
setup(props: { processedWidgets?: WidgetGridItem[] }) {
const widgets = computed(() =>
(props.processedWidgets ?? []).map((widget) => ({
name: widget.simplified.name,
value: widget.simplified.value,
options: { values: widget.simplified.options?.values }
}))
)
return { widgets }
},
template:
'<div data-testid="node-data">{{ JSON.stringify({ widgets }) }}</div>'
}
interface ProbedWidget {
@@ -39,10 +50,11 @@ function renderedWidgets(
render(LGraphNodePreview, {
props: { nodeDef: def, ...props },
global: {
plugins: [createTestingPinia({ stubActions: false })],
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: NodeWidgetsProbe
WidgetGrid: WidgetGridProbe
}
}
})
@@ -77,6 +89,17 @@ describe('LGraphNodePreview', () => {
expect(widget?.options?.values).toEqual(['a.safetensors', 'b.safetensors'])
})
it('leads with an explicitly empty provided value', () => {
const widget = renderedComboWidget({ widgetValues: { ckpt_name: '' } })
expect(widget?.value).toBe('')
expect(widget?.options?.values).toEqual([
'',
'a.safetensors',
'b.safetensors'
])
})
it('uses the input default when defined and empty string otherwise', () => {
const widgets = renderedWidgets(
fromPartial<ComfyNodeDefV2>({
@@ -92,4 +115,19 @@ describe('LGraphNodePreview', () => {
expect(widgets.find((w) => w.name === 'steps')?.value).toBe(20)
expect(widgets.find((w) => w.name === 'text')?.value).toBe('')
})
it('hides advanced widgets in previews', () => {
const widgets = renderedWidgets(
fromPartial<ComfyNodeDefV2>({
name: 'TestNode',
inputs: {
prompt: { type: 'STRING' },
sampler: { type: 'STRING', advanced: true }
},
outputs: []
})
)
expect(widgets.map((widget) => widget.name)).toEqual(['prompt'])
})
})

View File

@@ -19,9 +19,11 @@
>
<NodeSlots :node-data="nodeData" />
<NodeWidgets
v-if="nodeData.widgets?.length"
:node-data="nodeData"
<WidgetGrid
v-if="previewWidgets.length"
:processed-widgets="previewWidgets"
:node-type="nodeData.type"
:node-id="nodeData.id"
class="pointer-events-none"
/>
</div>
@@ -36,14 +38,17 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { toNodeId } from '@/types/nodeId'
import type { WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
const {
@@ -58,39 +63,9 @@ const {
const widgetStore = useWidgetStore()
// Convert nodeDef into VueNodeData
const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
// Preview nodes have no widget-value store entry, so combo widgets
// render their first option; lead with the requested value to show it.
const leadValue = widgetValues?.[name]
return {
nodeId: toNodeId('-1'),
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''),
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
: comboValues
} satisfies IWidgetOptions
}
})
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
.filter(([, input]) => !widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
name,
type: input.type,
@@ -119,16 +94,44 @@ const nodeData = computed<VueNodeData>(() => {
id: toNodeId(`preview-${nodeDef.name}`),
title: nodeDef.display_name || nodeDef.name,
type: nodeDef.name,
mode: 0, // Normal mode
mode: 0,
selected: false,
executing: false,
widgets,
inputs,
outputs,
flags: {
collapsed: false
}
}
})
const previewWidgets = computed<WidgetGridItem[]>(() =>
Object.entries(nodeDef.inputs || {})
.filter(
([, input]) =>
widgetStore.inputIsWidget(input) && !input.hidden && !input.advanced
)
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
const leadValue = widgetValues?.[name]
const value = (leadValue ??
input.default ??
comboValues?.[0] ??
'') as WidgetValue
const type = input.widgetType || input.type
const values =
leadValue !== undefined && comboValues
? [leadValue, ...comboValues.filter((option) => option !== leadValue)]
: comboValues
return {
visible: true,
renderKey: `preview:${nodeDef.name}:${name}`,
vueComponent: getComponent(type) ?? WidgetLegacy,
simplified: { name, type, value, options: { values }, spec: input }
}
})
)
</script>

View File

@@ -26,7 +26,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
mode: 0,
selected: false,
executing: false,
widgets: [],
inputs: [],
outputs: [],
flags: { collapsed: false },

View File

@@ -1,4 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { render } from '@testing-library/vue'
import type { RenderOptions } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -9,7 +11,9 @@ import type { PropType } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -38,7 +42,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})
@@ -425,6 +428,65 @@ describe('NodeSlots.vue', () => {
])
})
describe('unified mode', () => {
function renderUnified(
nodeData: VueNodeData,
pinia = createTestingPinia({ stubActions: false })
) {
setActivePinia(pinia)
return render(NodeSlots, {
global: {
plugins: [i18n, pinia],
stubs: defaultSlotStubs
},
props: { nodeData, unified: true }
})
}
function createConnectedGraph() {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
const graph = new LGraph()
useCanvasStore().canvas = fromPartial<LGraphCanvas>({ graph })
const upstream = new LGraphNode('Upstream')
upstream.id = toNodeId(1)
upstream.addOutput('out', 'FAKE')
graph.add(upstream)
const node = new LGraphNode('Target')
node.id = toNodeId(2)
node.addInput('plain', 'FAKE')
node.addInput('w', 'FAKE')
node.inputs[1].widget = { name: 'w' }
graph.add(node)
return { pinia, upstream, node }
}
it('reacts to connect and disconnect without a reprojection event', async () => {
const { pinia, upstream, node } = createConnectedGraph()
const nodeData = makeNodeData({
id: toVueNodeId(node.id),
inputs: node.inputs
})
const { container } = renderUnified(nodeData, pinia)
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('[data-name="w"]')).toBeNull()
upstream.connect(0, node, 1)
await nextTick()
expect(getRenderedSlotIndex(container, 'w')).toBe(1)
node.disconnectInput(1)
await nextTick()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
expect(container.querySelector('[data-name="w"]')).toBeNull()
})
})
it('remounts InputSlot when index shifts due to autogrow insertion', async () => {
const mountCounts = new Map<string, number>()
const initialInputs = [

View File

@@ -41,6 +41,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
linkedWidgetedInputs,
nonWidgetedInputs
@@ -58,11 +59,14 @@ interface NodeSlotsProps {
}
const { nodeData, unified = false } = defineProps<NodeSlotsProps>()
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const nodeLocatorId = computed(() => getLocatorIdFromNodeData(nodeData))
const linkedWidgetInputs = computed(() =>
unified ? linkedWidgetedInputs(nodeData) : []
unified && canvasStore.rootGraphId
? linkedWidgetedInputs(nodeData, canvasStore.rootGraphId)
: []
)
const filteredInputs = computed(() => [

View File

@@ -3,32 +3,23 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: {
id: GRAPH_ID
}
}
}
rootGraphId: GRAPH_ID
})
}))
@@ -36,7 +27,13 @@ const WidgetStub = {
name: 'WidgetStub',
props: ['widget', 'nodeId', 'nodeType', 'modelValue'],
template:
'<div class="widget-stub" :data-node-type="nodeType">{{ nodeType }}</div>'
'<div class="widget-stub" :data-node-type="nodeType" :data-name="widget.name">{{ nodeType }}</div>'
}
const AppInputStub = {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
vi.mock(
@@ -50,63 +47,78 @@ vi.mock(
}
)
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: toNodeId('test_node'),
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = [],
id: NodeId = toNodeId(1)
): VueNodeData => ({
function createMockNodeData(
nodeType = 'TestNode',
id: NodeId = toNodeId(1)
): VueNodeData {
return {
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
})
function renderComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true
},
mocks: {
$t: (key: string) => key
}
}
})
}
}
function registerWidgetState(
id: WidgetId,
init: {
type?: string
value?: unknown
options?: Record<string, unknown>
} = {}
) {
useWidgetValueStore().registerWidget(id, {
type: init.type ?? 'combo',
value: init.value ?? 'value',
options: init.options ?? {}
})
}
function renderComponent({
nodeData,
widgetIds,
setupStores
}: {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
setupStores?: () => void
}) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData,
widgetIds
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true,
AppInput: AppInputStub
},
mocks: {
$t: (key: string) => key
}
}
})
}
describe('NodeWidgets', () => {
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
const { container } = renderComponent(nodeData)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('CheckpointLoaderSimple')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -116,15 +128,31 @@ describe('NodeWidgets', () => {
})
it('renders no widgets when nodeData is undefined', () => {
const { container } = renderComponent(undefined)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const { container } = renderComponent({
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('renders no widgets when no widget ids are registered or passed', () => {
const { container } = renderComponent({
nodeData: createMockNodeData('CheckpointLoaderSimple')
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('passes empty string when nodeData.type is empty', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('', [widget])
const { container } = renderComponent(nodeData)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -132,7 +160,18 @@ describe('NodeWidgets', () => {
})
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
it('derives widget ids from the store when ids are not passed', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'test_widget')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('deduplicates repeated widget ids while keeping distinct widget ids', () => {
const duplicateEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
@@ -143,163 +182,34 @@ describe('NodeWidgets', () => {
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
'string_a'
)
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
widgetId: distinctEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
duplicateA,
duplicateB,
distinct
])
const nodeData = createMockNodeData('SubgraphNode')
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: false }
})
const nodeData = createMockNodeData('SubgraphNode', [
hiddenDuplicate,
visibleDuplicate
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('does not deduplicate entries that share names but have different widget types', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
textWidget,
comboWidget
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(19)])
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
secondTransientEntry
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:1'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:1'), 'text')
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:2'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:2'), 'text')
})
const nodeData = createMockNodeData('SubgraphNode', [
firstPromoted,
secondPromoted
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'test_widget',
options: { hidden: false }
})
])
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(
widgetId('graph-test', toNodeId('test_node'), 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
const { container } = renderComponent({
nodeData,
widgetIds: [duplicateEntityId, duplicateEntityId, distinctEntityId],
setupStores: () => {
registerWidgetState(duplicateEntityId, { type: 'text' })
registerWidgetState(distinctEntityId, { type: 'text' })
}
)
})
await nextTick()
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when store options mark them hidden', () => {
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const id = widgetId(GRAPH_ID, toNodeId('test_node'), 'test_widget')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => {
registerWidgetState(id, {
type: 'combo',
options: { hidden: true }
})
}
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
})
@@ -307,44 +217,17 @@ describe('NodeWidgets', () => {
it('forwards canonical widgetId to AppInput for selection', () => {
const seedAEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_a')
const seedBEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_b')
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_a',
type: 'text',
widgetId: seedAEntityId
}),
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_b',
type: 'text',
widgetId: seedBEntityId
})
])
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const { container } = render(NodeWidgets, {
props: { nodeData },
global: {
plugins: [
(() => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
return pinia
})()
],
stubs: {
InputSlot: true,
AppInput: {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
},
mocks: {
$t: (key: string) => key
}
const { container } = renderComponent({
nodeData,
widgetIds: [seedAEntityId, seedBEntityId],
setupStores: () => {
registerWidgetState(seedAEntityId, { type: 'text' })
registerWidgetState(seedBEntityId, { type: 'text' })
}
})
const appInputElements = container.querySelectorAll('.app-input-stub')
const ids = Array.from(appInputElements).map((el) =>
el.getAttribute('data-entity-id')
@@ -352,4 +235,35 @@ describe('NodeWidgets', () => {
expect(ids).toStrictEqual([seedAEntityId, seedBEntityId])
})
it('marks widgets with host execution errors', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'seed')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
widgetIds: [id],
setupStores: () => {
useExecutionErrorStore().lastNodeErrors = {
[createNodeExecutionId([nodeId])]: {
errors: [
{
type: 'value_not_in_list',
message: 'seed is invalid',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
registerWidgetState(id, { type: 'text' })
}
})
expect(container.querySelector('.widget-stub')?.className).toContain(
'text-node-stroke-error'
)
})
})

View File

@@ -2,104 +2,43 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.widgets', 'Node Widgets Error') }}
</div>
<div
<WidgetGrid
v-else
data-testid="node-widgets"
:processed-widgets
:node-type
:can-select-inputs
:node-id="nodeData?.id"
:class="
cn(
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1',
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
@pointerdown.capture="handleBringToFront"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch pr-3"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
/>
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { WidgetId } from '@/types/widgetId'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
}
const { nodeData } = defineProps<NodeWidgetsProps>()
const { nodeData, widgetIds } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -128,8 +67,10 @@ onErrorCaptured((error) => {
return false
})
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
useProcessedWidgets(() => nodeData)
const { canSelectInputs, nodeType, processedWidgets } = useProcessedWidgets(
() => nodeData,
() => widgetIds
)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {

View File

@@ -0,0 +1,104 @@
<template>
<div
data-testid="node-widgets"
class="lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.simplified.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.simplified.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.simplified.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-tooltip.left="widget.tooltipConfig ?? EMPTY_TOOLTIP"
:model-value="widget.simplified.value"
:widget="widget.simplified"
:node-id
:node-type
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import { shouldExpand } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { NodeId } from '@/types/nodeId'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
const EMPTY_TOOLTIP: TooltipOptions = {}
const {
processedWidgets,
nodeType,
canSelectInputs = false,
nodeId
} = defineProps<{
processedWidgets: WidgetGridItem[]
nodeType: string
canSelectInputs?: boolean
nodeId?: NodeId
}>()
const gridTemplateRows = computed(() =>
processedWidgets
.filter((widget) => widget.visible)
.map((widget) =>
shouldExpand(widget.simplified.type) || widget.hasLayoutSize
? 'auto'
: 'min-content'
)
.join(' ')
)
</script>

View File

@@ -2,7 +2,6 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { i18n, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
@@ -19,9 +18,8 @@ const positiveCoordsTooltipKey =
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const positiveCoordsWidget: SafeWidgetData = {
name: 'positive_coords',
type: 'STRING'
const positiveCoordsWidget: { name: string; tooltip?: string } = {
name: 'positive_coords'
}
function mergeOutputTooltipMessage(tooltip: string | null) {

View File

@@ -5,7 +5,6 @@ import type {
import { computed, ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st, stRaw } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -136,11 +135,11 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: SafeWidgetData) => {
const getWidgetTooltip = (widget: { name: string; tooltip?: string }) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
const widgetTooltip = (widget as { tooltip?: string }).tooltip
const widgetTooltip = widget.tooltip
if (widgetTooltip) return widgetTooltip
// Then try input-based tooltip lookup

View File

@@ -0,0 +1,197 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import type { LGraph, LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import {
trackNodePrice,
usePartitionedBadges
} from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useLinkStore } from '@/stores/linkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const GRAPH_ID = 'graph-test'
vi.mock('@/scripts/app', () => ({ app: {} }))
const settings = vi.hoisted(() => new Map<string, unknown>())
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (key: string) => settings.get(key) })
}))
vi.mock('@/composables/node/useNodePricing', () => {
const revision = ref(0)
return {
useNodePricing: () => ({
hasDynamicPricing: () => true,
getRelevantWidgetNames: () => [],
getInputNames: () => ['image'],
getInputGroupPrefixes: () => ['ref_images'],
getNodeRevisionRef: () => revision
})
}
})
function makeInputSlot(name: string): INodeInputSlot {
return { name, type: 'IMAGE', link: null, boundingRect: [0, 0, 0, 0] }
}
describe('trackNodePrice', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
useCanvasStore().currentGraph = fromPartial<LGraph>({
rootGraph: { id: GRAPH_ID }
})
// Instantiate up front so first-use state registration inside a test
// cannot masquerade as connectivity-driven invalidation.
useLinkStore()
useWidgetValueStore()
})
function trackedRuns(inputs: INodeInputSlot[]) {
let runs = 0
const tracker = computed(() => {
trackNodePrice({ id: '5', type: 'ApiNode', inputs })
runs += 1
return runs
})
return { read: () => tracker.value, runs: () => runs }
}
function connect(slot: number, linkId: number) {
useLinkStore().registerLink(GRAPH_ID, {
id: toLinkId(linkId),
originNodeId: toNodeId(99),
originSlot: 0,
targetNodeId: toNodeId(5),
targetSlot: slot,
type: 'IMAGE'
})
}
it('re-runs when a pricing-relevant input connects or disconnects', () => {
const inputs = [makeInputSlot('image'), makeInputSlot('other')]
const { read, runs } = trackedRuns(inputs)
read()
expect(runs()).toBe(1)
connect(0, 1)
read()
expect(runs()).toBe(2)
const linkStore = useLinkStore()
const topology = linkStore.getInputSlotLink(GRAPH_ID, toNodeId(5), 0)!
linkStore.deleteLink(GRAPH_ID, topology)
read()
expect(runs()).toBe(3)
})
it('re-runs when an input-group input connects', () => {
const inputs = [makeInputSlot('image'), makeInputSlot('ref_images.img0')]
const { read, runs } = trackedRuns(inputs)
read()
expect(runs()).toBe(1)
connect(1, 2)
read()
expect(runs()).toBe(2)
})
it('does not re-run when an irrelevant input connects', () => {
const inputs = [makeInputSlot('image'), makeInputSlot('other')]
// Seed the graph's link bucket so its creation is not what gets observed.
connect(7, 9)
const { read, runs } = trackedRuns(inputs)
read()
expect(runs()).toBe(1)
connect(1, 3)
read()
expect(runs()).toBe(1)
})
})
describe('usePartitionedBadges', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
settings.clear()
settings.set('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.ShowAll)
settings.set(
'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
NodeBadgeMode.ShowAll
)
settings.set('Comfy.NodeBadge.NodeSourceBadgeMode', NodeBadgeMode.None)
})
function addNodeDef(name: string, python_module: string) {
useNodeDefStore().addNodeDef(
fromPartial<ComfyNodeDef>({
name,
display_name: name,
category: 'test',
python_module,
input: {},
output: []
})
)
}
function makeNodeData(
type: string,
badges: Partial<LGraphBadge>[]
): VueNodeData {
return fromPartial<VueNodeData>({ id: '5', type, badges })
}
it('partitions badges into core, pricing, and extension rows', () => {
addNodeDef('CustomNode', 'custom_nodes.testpack')
const credits = usePriceBadge().getCreditsBadge('$0.05 x 3 Runs')
const partitioned = usePartitionedBadges(
makeNodeData('CustomNode', [
{ text: 'legacy-core-slot' },
credits,
{ text: 'EXT' },
{ text: '' }
])
)
expect(partitioned.value).toEqual({
hasComfyBadge: false,
core: [{ text: '#5' }],
extension: [expect.objectContaining({ text: 'EXT' })],
pricing: [{ required: '$0.05', rest: 'x 3 Runs' }]
})
})
it('shows the Comfy badge only for core nodes without pricing', () => {
settings.set('Comfy.NodeBadge.NodeSourceBadgeMode', NodeBadgeMode.ShowAll)
addNodeDef('CoreNode', 'nodes')
const withoutPricing = usePartitionedBadges(
makeNodeData('CoreNode', [{ text: 'legacy-core-slot' }])
)
expect(withoutPricing.value.hasComfyBadge).toBe(true)
const credits = usePriceBadge().getCreditsBadge('$0.10')
const withPricing = usePartitionedBadges(
makeNodeData('CoreNode', [{ text: 'legacy-core-slot' }, credits])
)
expect(withPricing.value.hasComfyBadge).toBe(false)
})
})

View File

@@ -8,13 +8,16 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { toNodeId } from '@/types/nodeId'
import type { SerializedNodeId } from '@/types/nodeId'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
import { widgetId } from '@/types/widgetId'
import type { UUID } from '@/utils/uuid'
function splitAroundFirstSpace(text: string): [string, string | undefined] {
const index = text.indexOf(' ')
@@ -27,7 +30,29 @@ type TrackableNode = {
type: string
inputs?: INodeInputSlot[]
}
//TODO deduplicate reactivity tracking once more thoroughly tested
/**
* Reads pricing-relevant slot connectivity from the link store so the
* calling computed re-runs when one of those inputs connects or disconnects.
*/
function touchPricingInputConnectivity(
graphId: UUID | undefined,
nodeId: NodeId,
inputs: INodeInputSlot[] | undefined,
inputNames: string[],
groupPrefixes: string[]
): void {
if (graphId === undefined || !inputs) return
if (inputNames.length === 0 && groupPrefixes.length === 0) return
const linkStore = useLinkStore()
inputs.forEach((inp, index) => {
const relevant =
(inp.name && inputNames.includes(inp.name)) ||
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
if (relevant) void linkStore.isInputSlotConnected(graphId, nodeId, index)
})
}
export function trackNodePrice(node: TrackableNode) {
const {
getRelevantWidgetNames,
@@ -36,7 +61,8 @@ export function trackNodePrice(node: TrackableNode) {
getInputNames,
getNodeRevisionRef
} = useNodePricing()
// Access per-node revision ref to establish dependency (each node has its own ref)
// Read the per-node revision ref even for static pricing: JSONata 2.x
// evaluation is async, so the badge must re-run when evaluation completes.
const nodeId = toNodeId(node.id)
void getNodeRevisionRef(nodeId).value
@@ -45,7 +71,7 @@ export function trackNodePrice(node: TrackableNode) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = getRelevantWidgetNames(node.type)
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
const graphId = useCanvasStore().rootGraphId
if (relevantNames.length > 0 && node.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
@@ -53,24 +79,13 @@ export function trackNodePrice(node: TrackableNode) {
void widgetStore.getWidget(widgetId(graphId, nodeId, name))?.value
}
}
// Access input connections for regular inputs
const inputNames = getInputNames(node.type)
if (inputNames.length > 0) {
node?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = getInputGroupPrefixes(node.type)
if (groupPrefixes.length > 0) {
node?.inputs?.forEach((inp) => {
if (groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))) {
void inp.link // Access link to create reactive dependency
}
})
}
touchPricingInputConnectivity(
graphId,
nodeId,
node.inputs,
getInputNames(node.type),
getInputGroupPrefixes(node.type)
)
}
/**
@@ -106,78 +121,15 @@ function trackSubgraphInnerNodePrices(wrapper: LGraphNode) {
}
export function usePartitionedBadges(nodeData: VueNodeData) {
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
const { isCreditsBadge } = usePriceBadge()
const settingStore = useSettingStore()
// Cache pricing metadata (won't change during node lifetime)
const isDynamicPricing = computed(() =>
nodeData?.apiNode ? hasDynamicPricing(nodeData.type) : false
)
const relevantPricingWidgets = computed(() =>
nodeData?.apiNode ? getRelevantWidgetNames(nodeData.type) : []
)
const inputGroupPrefixes = computed(() =>
nodeData?.apiNode ? getInputGroupPrefixes(nodeData.type) : []
)
const relevantInputNames = computed(() =>
nodeData?.apiNode ? getInputNames(nodeData.type) : []
)
const unpartitionedBadges = computed<NodeBadgeProps[]>(() => {
if (nodeData?.id != null) {
const wrapper = app.canvas?.graph?.getNodeById(nodeData.id)
if (wrapper?.isSubgraphNode()) trackSubgraphInnerNodePrices(wrapper)
}
// For ALL API nodes: access per-node revision ref to detect when async pricing evaluation completes
// This is needed even for static pricing because JSONata 2.x evaluation is async
if (nodeData?.apiNode && nodeData?.id != null) {
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(nodeData.id).value
// For dynamic pricing, also track widget values and input connections
if (isDynamicPricing.value) {
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = relevantPricingWidgets.value
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
if (relevantNames.length > 0 && nodeData?.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(widgetId(graphId, nodeData.id, name))
?.value
}
}
// Access input connections for regular inputs
const inputNames = relevantInputNames.value
if (inputNames.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = inputGroupPrefixes.value
if (groupPrefixes.length > 0) {
nodeData?.inputs?.forEach((inp) => {
if (
groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))
) {
void inp.link // Access link to create reactive dependency
}
})
}
}
}
if (nodeData?.apiNode && nodeData?.id != null) trackNodePrice(nodeData)
return [...(nodeData?.badges ?? [])].map(toValue)
})
const nodeDef = useNodeDefStore().nodeDefsByName[nodeData.type]

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