Compare commits

...

40 Commits

Author SHA1 Message Date
Alexander Brown
00cfb70ad1 merge: resolve conflict in litegraphUtil.test.ts keeping both sides
Amp-Thread-ID: https://ampcode.com/threads/T-019cbbd4-2ada-7557-8a34-23a174f34f9a
Co-authored-by: Amp <amp@ampcode.com>
2026-03-04 18:31:22 -08:00
Alexander Brown
0b8cc7f3ca fix: address CodeRabbit review findings for subgraph lifecycle parity
- Emit dispatchNodeConnectionChange in SubgraphInputNode stale-link fallback
- Balance beforeChange/afterChange with try/finally in SubgraphOutput.connect
- Emit disconnect event when SubgraphOutput.connect replaces existing link
- Guard connectSubgraphOutputSlot return and skip dispatch on falsy link
- Move onAfterGraphConfigured to finally block in graphConfigureUtil
- Add dispatchSlotLinkChanged to connectSubgraphOutputSlot for parity
- Guard remapProxyWidgets call with explicit map-entry check
- Harden LinkStore restore test assertions with existence checks
- Use dispatchDisconnectNodePair in disconnectOutput for consistency

Amp-Thread-ID: https://ampcode.com/threads/T-019cb1e7-a18e-712a-b8c7-4448f603c8b1
Co-authored-by: Amp <amp@ampcode.com>
2026-03-03 01:20:30 -08:00
Alexander Brown
838b168ea9 Merge branch 'main' into drjkl/he-come-to-town 2026-03-02 19:38:21 -08:00
Alexander Brown
7b41b03af2 refactor: simplify LGraph with L3, L4, L7 remediation items
- L7: Extract resolveCanonicalSlotName and normalizeLegacySlotIdentity to utils/slotIdentity.ts (-45 lines from LGraph.ts)

- L3: Inline 4 trivial subgraphBoundaryAdapter wrappers using existing LLink getters (-26 lines from subgraphUtils.ts)

- L4: Merge hasLegacyLinkInputSlotMismatch into fixLinkInputSlots single-pass (-17 lines, eliminates double traversal)

Amp-Thread-ID: https://ampcode.com/threads/T-019ca83b-1182-77df-b270-4703bb00cf45
Co-authored-by: Amp <amp@ampcode.com>
2026-03-01 10:45:27 -08:00
Alexander Brown
2cc3997fc1 refactor: PR #9247 should-fix items S1–S11
- S1: Extract _createAndRegisterLink helper from 3 connect methods
- S2: Remove dead SubgraphInputNode.connectSlots (unregistered LLinks)
- S3: Document that LGraph.connectSlots callers dispatch callbacks
- S4: Add dispatchSlotLinkChanged to connectSubgraphInputSlot
- S5: Document _version increment contract on disconnect paths
- S6: Add backwards-compat comments at 3 behavioral change sites
- S7: Gate SubgraphOutput.disconnect deprecation warning with once flag
- S8: Fix splitPositionables instanceof ordering (subclass before base)
- S9: Add explicit return type to mapReroutes
- S10: Add JSDoc to LinkStoreTopology about mutable backing maps
- S11: Document dispatch ordering asymmetry in event dispatcher

Amp-Thread-ID: https://ampcode.com/threads/T-019ca787-c4d4-755f-b63a-4d814ff46e2c
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 23:05:19 -08:00
Alexander Brown
8a3c647261 refactor: simplify SafeWidgetData and useGraphNodeManager
- Remove redundant storeNodeId and storeName from SafeWidgetData
- Extract buildSlotMetadata helper to deduplicate slot metadata logic
- Simplify normalizeWidgetValue to collapse identity branches
- Collapse duplicated flags.* property handlers into single case
- Merge color/bgcolor handlers with computed property key
- Remove redundant syncWithGraph (existing-nodes loop covers it)
- Inline trigger dispatch, remove intermediate triggerHandlers map
- Avoid duplicate extractWidgetDisplayOptions calls in common path
- Remove unused LGraphTriggerAction import

Amp-Thread-ID: https://ampcode.com/threads/T-019ca721-4ac5-730e-a526-6c71e474e766
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 19:51:21 -08:00
Alexander Brown
f0ca965892 fix: address must-fix review items for link topology PR
- Fix copy-paste error in SubgraphOutput type error message
- Add linkStore.clearGraph() call in LGraph.clear() to prevent memory leak
- Cache useLinkStore() reference on LGraph instance for hot-path perf
- Use frozen EMPTY_TOPOLOGY sentinel instead of allocating per miss
- Make canonicalName required in SubgraphEventMap (always provided)
- Replace deprecated links[linkId] with links.get() in SubgraphNode
- Document connectSlots non-nullable return contract

Amp-Thread-ID: https://ampcode.com/threads/T-019ca6ba-5e48-707b-8c81-3eda7f6bc9e2
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 18:01:05 -08:00
Alexander Brown
767f19f0c3 fix: replace graph.linkStore with raw map lookups in changeTracker test
graph.linkStore was removed; use graph.links, graph.floatingLinks, and graph.reroutes maps directly to verify store-backed accessor parity.

Amp-Thread-ID: https://ampcode.com/threads/T-019ca6ac-9a9e-7139-8b31-dee2bec82341
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 15:57:21 -08:00
Alexander Brown
d6e05bd11a fix: use Map lookup for subgraph output disconnect
- Replace deprecated indexed link access with Map#get in SubgraphOutput.disconnect

- Add behavior-focused regression coverage for idempotent output disconnect

Amp-Thread-ID: https://ampcode.com/threads/T-019ca639-cde7-744d-b426-6ea21b2159e6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 14:29:59 -08:00
Alexander Brown
0a940d62b9 Merge origin/main into drjkl/he-come-to-town
Amp-Thread-ID: https://ampcode.com/threads/T-019ca639-cde7-744d-b426-6ea21b2159e6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-28 14:13:51 -08:00
Alexander Brown
779694cfc8 fix: stabilize nested subgraph promoted widget resolution (#9282)
Fix multiple issues with promoted widget resolution in nested subgraphs,
ensuring correct value propagation, slot matching, and rendering for
deeply nested promoted widgets.

- **What**: Stabilize nested subgraph promoted widget resolution chain
- Use deep source keys for promoted widget values in Vue rendering mode
- Resolve effective widget options from the source widget instead of the
promoted view
  - Stabilize slot resolution for nested promoted widgets
  - Preserve combo value rendering for promoted subgraph widgets
- Prevent subgraph definition deletion while other nodes still reference
the same type
  - Clean up unused exported resolution types

- `resolveConcretePromotedWidget.ts` — new recursive resolution logic
for deeply nested promoted widgets
- `useGraphNodeManager.ts` — option extraction now uses
`effectiveWidget` for promoted widgets
- `SubgraphNode.ts` — unpack no longer force-deletes definitions
referenced by other nodes

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9282-fix-stabilize-nested-subgraph-promoted-widget-resolution-3146d73d365081208a4fe931bb7569cf)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-02-28 14:12:45 -08:00
pythongosssss
6464d14fe8 Add indicator circle when new unseen menu items are available (#9220)
## Summary

Adds a little indicator circle when new workflow menu items are added
that the user has not seen

## Changes

- **What**: Adds a hidden setting to track menu items flagged as new
that have been seen

## Screenshots (if applicable)

<img width="164" height="120" alt="image"
src="https://github.com/user-attachments/assets/ac36673d-fbf1-42ff-9a9e-1371eb96115b"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9220-Add-indicator-circle-when-new-unseen-menu-items-are-available-3126d73d3650819cb8cde854d6b6510b)
by [Unito](https://www.unito.io)
2026-02-28 14:06:48 -08:00
Terry Jia
2182375e5e feat: wrap CURVE widget value with typed format (#9294)
## Summary
Send CURVE values as { __type: 'CURVE', value: [...] } instead of {
__value__: [...] } to avoid ambiguity with link detection and enable
external tools to identify the data type.

change requested by @guill

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9294-feat-wrap-CURVE-widget-value-with-typed-format-3156d73d365081bf8e5de59527e2d3ce)
by [Unito](https://www.unito.io)
2026-02-28 14:06:45 -08:00
Christian Byrne
e0cc4d93da feat: Node Library sidebar and V2 Search dialog UI/UX updates (#9085)
## Summary

Implement 11 Figma design discrepancies for the Node Library sidebar and
V2 Node Search dialog, aligning the UI with the [Toolbox Figma
design](https://www.figma.com/design/xMFxCziXJe6Denz4dpDGTq/Toolbox?node-id=2074-21394&m=dev).

## Changes

- **What**: Sidebar: reorder tabs (All/Essentials/Blueprints), rename
Custom→Blueprints, uppercase section headers, chevron-left of folder
icon, bookmark-on-hover for node rows, filter dropdown with checkbox
items, sort labels (Categorized/A-Z) with label-left/check-right layout,
hide section headers in A-Z mode. Search dialog: expand filter chips
from 3→6, add Recents and source categories to sidebar, remove "Filter
by" label. Pull foundation V2 components from merged PR #8548.
- **Dependencies**: Depends on #8987 (V2 Node Search) and #8548
(NodeLibrarySidebarTabV2)

## Review Focus

- Filter dropdown (`filterOptions`) is UI-scaffolded but not yet wired
to filtering logic (pending V2 integration)
- "Recents" category currently returns frequency-based results as
placeholder until a usage-tracking store is implemented
- Pre-existing type errors from V2 PR dependencies not in the base
commit (SearchBoxV2, usePerTabState, TextTicker, getProviderIcon,
getLinkTypeColor, SidebarContainerKey) are expected and will resolve
when rebased onto main after parent PRs land

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9085-feat-Node-Library-sidebar-and-V2-Search-dialog-Figma-design-improvements-30f6d73d36508175bf72d716f5904476)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 14:06:42 -08:00
jaeone94
e2ad7a58a9 refactor(node-replacement): reorganize domain components and expand comprehensive test suite (#9301)
## Summary

Resolves six open issues by reorganizing node replacement components
into a domain-driven folder structure, refactoring event handling to
follow the emit pattern, and adding comprehensive test coverage across
all affected modules.

## Changes

- **What**:
- Moved `SwapNodeGroupRow.vue` and `SwapNodesCard.vue` from
`src/components/rightSidePanel/errors/` to
`src/platform/nodeReplacement/components/` (Issues #9255)
- Moved `useMissingNodeScan.ts` from `src/composables/` to
`src/platform/nodeReplacement/missingNodeScan.ts`, renamed to reflect it
is a plain function not a Vue composable (Issues #9254)
- Refactored `SwapNodeGroupRow.vue` to emit a `'replace'` event instead
of calling `useNodeReplacement()` and `useExecutionErrorStore()`
directly; replacement logic now handled in `TabErrors.vue` (Issue #9267)
- Added unit tests for `removeMissingNodesByType`
(`executionErrorStore.test.ts`), `scanMissingNodes`
(`missingNodeScan.test.ts`), and `swapNodeGroups` computed
(`swapNodeGroups.test.ts`, `useErrorGroups.test.ts`) (Issue #9270)
- Added placeholder detection tests covering unregistered-type detection
when `has_errors` is false, and exclusion of registered types
(`useNodeReplacement.test.ts`) (Issue #9271)
- Added component tests for `MissingNodeCard` and `MissingPackGroupRow`
covering rendering, expand/collapse, events, install states, and edge
cases (Issue #9231)
- Added component tests for `SwapNodeGroupRow` and `SwapNodesCard`
(Issues #9255, #9267)

## Additional Changes (Post-Review)

- **Edge case guard in placeholder detection**
(`useNodeReplacement.ts`): When `last_serialization.type` is absent (old
serialization format), the predicate falls back to `n.type`, which the
app may have already run through `sanitizeNodeName` — stripping HTML
special characters (`& < > " ' \` =`). In that case, a `Set.has()`
lookup against the original unsanitized type name would silently miss,
causing replacement to be skipped.

Fixed by including sanitized variants of each target type in the
`targetTypes` Set at construction time. For the overwhelmingly common
case (no special characters in type names), the Set deduplicates the
entries and runtime behavior is identical to before.

A regression test was added to cover the specific scenario:
`last_serialization.type` absent + live `n.type` already sanitized.

## Review Focus

- `TabErrors.vue`: confirm the new `@replace` event handler correctly
replaces nodes and removes them from missing nodes list (mirrors the old
inline logic in `SwapNodeGroupRow`)
- `missingNodeScan.ts`: filename/export name change from
`useMissingNodeScan` — verify all call sites updated via `app.ts`
- Test mocking strategy: module-level `vi.mock()` factories use closures
over `ref`/plain objects to allow per-test overrides without global
mutable state

- Fixes #9231
- Fixes #9254
- Fixes #9255
- Fixes #9267
- Fixes #9270
- Fixes #9271
2026-02-28 14:06:39 -08:00
jaeone94
1917804190 fix: node replacement fails after execution and modal sync (#9269)
## Summary

Fixes two bugs in the node replacement flow: placeholder detection
failing after workflow execution or pack reinstallation, and missing UI
sync in the Errors Tab when replacements are applied from the modal
dialog.

## Changes

- **Placeholder detection**: Node placeholder detection now matches
against `targetTypes` (derived from the replaceable node list built at
workflow load time) instead of relying on `has_errors` flag or
`registered_node_types` lookup. This ensures replacement works reliably
after execution (where `has_errors` gets cleared) and after pack
reinstallation (where the type becomes registered).
- **Modal → Errors Tab sync**: Added
`executionErrorStore.removeMissingNodesByType()` call in
`MissingNodesContent.vue` after replacement, so the Errors Tab reflects
changes immediately without requiring a page reload.

## Review Focus

- `collectAllNodes` predicate change in `useNodeReplacement.ts`: now
uses `targetTypes.has(originalType)` to find nodes by their original
serialized type. This is independent of runtime state like `has_errors`
or `registered_node_types`.
- `executionErrorStore.removeMissingNodesByType` call timing in
`MissingNodesContent.vue` — runs synchronously after
`replaceNodesInPlace` resolves, before auto-close logic.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9269-fix-node-replacement-fails-after-execution-and-modal-sync-3146d73d365081218398c961639b450f)
by [Unito](https://www.unito.io)
2026-02-28 14:06:37 -08:00
Comfy Org PR Bot
ab59e373fa 1.41.8 (#9288)
Patch version increment to 1.41.8

**Base branch:** `main`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9288-1-41-8-3156d73d3650817ca737ced3e08d8c86)
by [Unito](https://www.unito.io)

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-02-28 14:06:34 -08:00
Christian Byrne
237e210962 fix: pre-rasterize SubgraphNode SVG icon to bitmap canvas (#9172)
## Summary

Pre-rasterize the SubgraphNode SVG icon to a bitmap canvas to eliminate
Firefox's per-frame SVG style processing.

## Changes

- **What**: Add `getWorkflowBitmap()` that lazily rasterizes the
`data:image/svg+xml` workflow icon to an `HTMLCanvasElement` (16×16) on
first use. `SubgraphNode.drawTitleBox()` draws the cached bitmap instead
of the raw SVG.

## Review Focus

- Firefox re-processes SVG internal stylesheets (`stroke`,
`stroke-linecap`, `stroke-width`) every time `ctx.drawImage(svgImage)`
is called. Chrome caches the rasterization. This happens on every frame
for every visible SubgraphNode.
- Reporter confirmed strong subgraph correlation: "it may be happening
in the default workflow with subgraph" / "didn't seem to happen just
using manually wired up diffusion loader, clip, sampler, etc."
- Falls back to the raw SVG Image if not yet loaded or if
`getContext('2d')` returns null.

## Stack

3 of 4 in Firefox perf fix stack. Depends on #9170.

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9172-fix-pre-rasterize-SubgraphNode-SVG-icon-to-bitmap-canvas-3116d73d365081babf17cf0848d37269)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-02-28 14:06:33 -08:00
AustinMroz
13040e700d Fix essentials nodes not being marked core (#9287)
In adding an essentials cateogory for nodes, #8987 introduced a
regression where core nodes which are also essential are marked as being
from a `nodes` custom node instead of being marked core. Since the
essentials designation should pre-empt core and custom nodes can choose
to mark themself as essential, the getter for `isCoreNode` is updated to
instead repeat the existing check for if a node is core.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/f1b8bf80-d072-409a-a0f9-4837e1d11767"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/14ff525b-9833-4e73-888f-791aff6cf531"/>|

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9287-Fix-essentials-nodes-not-being-marked-core-3146d73d365081fca2a0f8bdc2baf01a)
by [Unito](https://www.unito.io)
2026-02-28 14:06:31 -08:00
pythongosssss
bd126698f7 App builder exit updates (#9218)
## Summary

- remove exit builder button from right panel
- add builder exit button to bottom of canvas
- add builder menu with save & exit in top left

## Screenshots (if applicable)

<img width="1544" height="998" alt="image"
src="https://github.com/user-attachments/assets/f5deadc5-2bf5-4729-b644-2b6a815b9975"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9218-App-builder-exit-updates-3126d73d365081a0bf1adf92e1171060)
by [Unito](https://www.unito.io)
2026-02-28 14:06:29 -08:00
pythongosssss
de8be5c14a App mode - discard slow preview messages to prevent overwriting output image (#9261)
## Summary

Prevent latent previews received after the job/node has already finished
processing overwriting the actual output display

## Changes

- **What**: 
- updates job preview store to also track which node the preview was for
- updates linear progress tracking to store executed nodes enabling
skipping previews of these

## Review Focus

<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9261-App-mode-discard-slow-preview-messages-to-prevent-overwriting-output-image-3136d73d3650817884c2ce2ff5993b9e)
by [Unito](https://www.unito.io)
2026-02-28 14:06:28 -08:00
pythongosssss
9d4ae0ddca Render app builder in arrange mode (#9260)
## Summary

Adds app builder in arrange/preview mode with re-orderable widgets,
maintaining size (as much as possible) between the select + preview
steps

## Changes

- **What**: 
- Extract sidebar size constants for sharing between canvas splitter +
app mode
- Add widget list using DraggableList and render inert WidgetItems

## Screenshots (if applicable)

<img width="1391" height="923" alt="image"
src="https://github.com/user-attachments/assets/3e17eafe-db1e-40a3-83b5-15a7d0672909"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9260-Render-app-builder-in-arrange-mode-3136d73d365081ef875acab683d01d9e)
by [Unito](https://www.unito.io)
2026-02-28 14:06:26 -08:00
Alexander Brown
b9c467577c Merge branch 'main' into drjkl/he-come-to-town 2026-02-26 23:49:59 -08:00
Alexander Brown
9028ee9cfe fix: keep link store topology type internal
- remove unused exported LinkStoreTopology type to satisfy knip pre-push checks

Amp-Thread-ID: https://ampcode.com/threads/T-019c9c03-dca7-757a-af61-5f41ae8d2dec
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 15:07:39 -08:00
Alexander Brown
8026585721 fix: finalize link topology store migration
- switch LGraph link topology lookups and rehydration to linkStoreKey

- update litegraph tests to use graph-scoped linkStoreKey instead of graph.id

- add legacy compatibility warnings for slot dedupe, fixLinkInputSlots narrowing, and subgraph output disconnect callback parity

- initialize testing Pinia globally in vitest setup and document non-reactive link store intent

Amp-Thread-ID: https://ampcode.com/threads/T-019c9c03-dca7-757a-af61-5f41ae8d2dec
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 15:05:30 -08:00
Alexander Brown
9f1376d79e refactor: extract addAfterConfigureHandler to graphConfigureUtil
Move private method from ComfyApp to a standalone exported utility function.

Eliminates unsafe 'as any' cast in tests by making the function directly importable.

Amp-Thread-ID: https://ampcode.com/threads/T-019c9bda-0293-7528-9b0a-f72444199cbe
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 13:38:21 -08:00
Alexander Brown
2686becdb9 fix: correct disconnect dispatch args, import typo, and test timeout
- LGraphNode: use link_info.origin_slot instead of array index for
  sourceSlotIndex in dispatchDisconnectNodePair
- SubgraphInputNode: pass node input slot instead of subgraphInput
  for INPUT disconnect dispatch
- SubgraphIO.test: fix double slash in import path
- NightlySurveyPopover.test: remove unnecessary 15000ms timeout
  on fake-timer test

Amp-Thread-ID: https://ampcode.com/threads/T-019c9bcb-2c95-712d-8978-ab8e9e688e4d
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 13:25:15 -08:00
Alexander Brown
3331ab90f2 fix: harden subgraph disconnect cleanup for stale links
Amp-Thread-ID: https://ampcode.com/threads/T-019c9bb5-a77c-72ef-9b51-5394c20ee671
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 13:08:48 -08:00
Alexander Brown
7d737a534d Fix: expectation on renaming. The label and name now align, deduping happens on manual rename 2026-02-26 12:28:36 -08:00
Alexander Brown
0331c1c726 test: stabilize flaky unit expectations
Amp-Thread-ID: https://ampcode.com/threads/T-019c98dc-6f6a-7125-b046-418a6ccdffb1
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 01:42:38 -08:00
Alexander Brown
025a803ac0 feat: narrow legacy slot repair to mismatch cases
Amp-Thread-ID: https://ampcode.com/threads/T-019c98dc-6f6a-7125-b046-418a6ccdffb1
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 23:39:57 -08:00
Alexander Brown
34786a16c0 feat: unify lifecycle event dispatcher callbacks
Amp-Thread-ID: https://ampcode.com/threads/T-019c98d1-7608-73e5-9da5-3ae48178b28b
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 23:30:37 -08:00
Alexander Brown
6580500206 feat: isolate persistence and node remap adapters
Amp-Thread-ID: https://ampcode.com/threads/T-019c98c7-4fff-75cc-8950-0db38ec987fd
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 23:18:05 -08:00
Alexander Brown
e17a2c669b feat: extract shared subgraph boundary remap adapter
Amp-Thread-ID: https://ampcode.com/threads/T-019c98bb-ebfb-77fc-b105-1d04e023db7a
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 23:07:34 -08:00
Alexander Brown
1c4c000745 feat: normalize subgraph slot identity by canonical name
Amp-Thread-ID: https://ampcode.com/threads/T-019c98b1-8cb2-704f-b7ac-7accee5228d0
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:54:50 -08:00
Alexander Brown
38ed3124a0 feat: centralize subgraph io mutation paths
Amp-Thread-ID: https://ampcode.com/threads/T-019c98a5-85ea-75ec-9594-aea357107cf6
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:41:39 -08:00
Alexander Brown
ae5bfe9428 feat: centralize normal connect mutation path
Amp-Thread-ID: https://ampcode.com/threads/T-019c989e-444b-73fd-b60d-9d9e447d1212
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:28:57 -08:00
Alexander Brown
6c94b9faf8 feat: centralize disconnect/remove link mutation path
Amp-Thread-ID: https://ampcode.com/threads/T-019c9896-0c38-760a-a51c-3750734becc1
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:23:02 -08:00
Alexander Brown
bad3fa5f2f feat: route link topology reads through store projection
Amp-Thread-ID: https://ampcode.com/threads/T-019c9886-c7b7-73f0-b85a-8a046e556ca8
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 22:10:23 -08:00
Alexander Brown
1efc931e2e feat: add link store lifecycle rehydration contract
- add passive LinkStore for links, floating links, and reroutes

- rehydrate LinkStore on graph clear and configure lifecycle paths

- add unit and browser tests for ChangeTracker/link topology undo-redo parity

Amp-Thread-ID: https://ampcode.com/threads/T-019c9840-2f49-701c-bdf2-ae7f2f50acef
Co-authored-by: Amp <amp@ampcode.com>
2026-02-25 21:57:12 -08:00
36 changed files with 2848 additions and 755 deletions

View File

@@ -3,6 +3,7 @@ import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { WorkspaceStore } from '../types/globals'
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
@@ -63,6 +64,99 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(2)
})
test('undo/redo restores link topology with reroutes and floating links', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
const readTopology = () =>
comfyPage.page.evaluate(() => {
const graph = window.app!.rootGraph
return {
links: graph.links.size,
floatingLinks: graph.floatingLinks.size,
reroutes: graph.reroutes.size,
serialised: graph.serialize()
}
})
const baseline = await readTopology()
await beforeChange(comfyPage)
await comfyPage.page.evaluate(() => {
const graph = window.app!.rootGraph
const firstLink = graph.links.values().next().value
if (!firstLink) throw new Error('Expected at least one link')
const reroute = graph.createReroute(
[firstLink.id * 5, firstLink.id * 3],
firstLink
)
graph.addFloatingLink(firstLink.toFloating('output', reroute.id))
})
await afterChange(comfyPage)
const mutated = await readTopology()
expect(mutated.floatingLinks).toBeGreaterThan(baseline.floatingLinks)
expect(mutated.reroutes).toBeGreaterThan(baseline.reroutes)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
await comfyPage.page.evaluate(async () => {
await (
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.undo()
})
const afterUndo = await readTopology()
expect(afterUndo.links).toBe(baseline.links)
expect(afterUndo.floatingLinks).toBe(baseline.floatingLinks)
expect(afterUndo.reroutes).toBe(baseline.reroutes)
expect(afterUndo.serialised).toEqual(baseline.serialised)
await comfyPage.page.evaluate(async () => {
await (
window.app!.extensionManager as WorkspaceStore
).workflow.activeWorkflow?.changeTracker.redo()
})
const afterRedo = await readTopology()
expect(afterRedo.links).toBe(mutated.links)
expect(afterRedo.floatingLinks).toBe(mutated.floatingLinks)
expect(afterRedo.reroutes).toBe(mutated.reroutes)
expect(afterRedo.serialised).toEqual(mutated.serialised)
})
test('read-through accessors stay in sync for links, floating links, and reroutes', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('links/batch_move_links')
const parity = await comfyPage.page.evaluate(() => {
const graph = window.app!.rootGraph
const firstLink = graph.links.values().next().value
if (!firstLink) throw new Error('Expected at least one link')
const reroute = graph.createReroute(
[firstLink.id * 7, firstLink.id * 4],
firstLink
)
const floatingLink = firstLink.toFloating('output', reroute.id)
graph.addFloatingLink(floatingLink)
return {
normalLinkMatches:
graph.getLink(firstLink.id) === graph.links.get(firstLink.id),
floatingLinkRequiresExplicitProjection:
graph.getLink(floatingLink.id) === undefined &&
graph.floatingLinks.get(floatingLink.id) !== undefined,
rerouteMatches:
graph.getReroute(reroute.id) === graph.reroutes.get(reroute.id)
}
})
expect(parity.normalLinkMatches).toBe(true)
expect(parity.floatingLinkRequiresExplicitProjection).toBe(true)
expect(parity.rerouteMatches).toBe(true)
})
})
test('Can group multiple change actions into a single transaction', async ({

View File

@@ -87,9 +87,9 @@ test.describe('Subgraph Slot Rename Dialog', { tag: '@subgraph' }, () => {
// Get the current value in the prompt dialog
const dialogValue = await comfyPage.page.inputValue(SELECTORS.promptDialog)
// This should show the current label (RENAMED_NAME), not the original name
// This should show the current renamed value and stay aligned with slot identity.
expect(dialogValue).toBe(RENAMED_NAME)
expect(dialogValue).not.toBe(afterFirstRename.name) // Should not show the original slot.name
expect(dialogValue).toBe(afterFirstRename.name)
// Complete the second rename to ensure everything still works
await comfyPage.page.fill(SELECTORS.promptDialog, '')

View File

@@ -310,8 +310,8 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
expect(mappedWidget?.name).toBe('picker')
expect(mappedWidget?.nodeId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})

View File

@@ -29,7 +29,6 @@ import type {
LGraph,
LGraphBadge,
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
@@ -48,9 +47,7 @@ export interface WidgetSlotMetadata {
*/
export interface SafeWidgetData {
nodeId?: NodeId
storeNodeId?: NodeId
name: string
storeName?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
@@ -162,33 +159,16 @@ function getSharedWidgetEnhancements(
}
}
/**
* 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 (
value == null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
typeof value === 'boolean' ||
typeof value === 'object'
) {
return value
return value as WidgetValue
}
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
}
@@ -273,56 +253,55 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const isPromoted = isPromotedWidgetView(widget)
const isPromotedPseudoWidget =
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
isPromoted && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
const resolved =
isPromoted && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const { widget: sourceWidget, node: sourceNode } =
resolved?.status === 'resolved'
? resolved.resolved
: { widget: undefined, node: undefined }
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
const localId = isPromoted
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const storeName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = storeName ?? displayName
const name = isPromoted
? (sourceWidget?.name ??
promotedSource?.sourceWidgetName ??
displayName)
: displayName
const options =
effectiveWidget !== widget
? (extractWidgetDisplayOptions(effectiveWidget) ??
extractWidgetDisplayOptions(widget))
: extractWidgetDisplayOptions(widget)
return {
nodeId,
storeNodeId: nodeId,
name,
storeName,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
? { ...options, canvasOnly: true }
: options,
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined
}
@@ -335,6 +314,18 @@ function safeWidgetMapper(
}
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined
): Map<string, WidgetSlotMetadata> {
const slotMetadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
const slotInfo = { index, linked: input.link != null }
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
return slotMetadata
}
// 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
@@ -342,8 +333,6 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
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>()
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
@@ -391,16 +380,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
slotMetadata.clear()
node.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
const slotMetadata = buildSlotMetadata(node.inputs)
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
@@ -453,19 +433,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (!nodeRef || !currentData) return
// Only extract slot-related data instead of full node re-extraction
const slotMetadata = new Map<string, WidgetSlotMetadata>()
const slotMetadata = buildSlotMetadata(nodeRef.inputs)
nodeRef.inputs?.forEach((input, index) => {
const slotInfo = {
index,
linked: input.link != null
}
if (input.name) slotMetadata.set(input.name, slotInfo)
if (input.widget?.name) slotMetadata.set(input.widget.name, slotInfo)
})
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
const slotInfo = slotMetadata.get(widget.slotName ?? widget.name)
if (slotInfo) widget.slotMetadata = slotInfo
@@ -477,31 +446,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
return nodeRefs.get(id)
}
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
vueNodeData.delete(id)
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = String(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
@@ -626,123 +570,80 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
handleNodeRemoved(node, originalOnNodeRemoved)
}
const triggerHandlers: {
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
} = {
'node:property:changed': (propertyEvent) => {
const nodeId = String(propertyEvent.nodeId)
const currentData = vueNodeData.get(nodeId)
const handlePropertyChanged = (
propertyEvent: LGraphTriggerParam<'node:property:changed'>
) => {
const nodeId = String(propertyEvent.nodeId)
const currentData = vueNodeData.get(nodeId)
if (!currentData) return
if (currentData) {
switch (propertyEvent.property) {
case 'title':
vueNodeData.set(nodeId, {
...currentData,
title: String(propertyEvent.newValue)
})
break
case 'flags.collapsed':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(propertyEvent.newValue)
}
})
break
case 'flags.ghost':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
ghost: Boolean(propertyEvent.newValue)
}
})
break
case 'flags.pinned':
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
pinned: Boolean(propertyEvent.newValue)
}
})
break
case 'mode':
vueNodeData.set(nodeId, {
...currentData,
mode:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: 0
})
break
case 'color':
vueNodeData.set(nodeId, {
...currentData,
color:
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
break
case 'bgcolor':
vueNodeData.set(nodeId, {
...currentData,
bgcolor:
typeof propertyEvent.newValue === 'string'
? propertyEvent.newValue
: undefined
})
break
case 'shape':
vueNodeData.set(nodeId, {
...currentData,
shape:
typeof propertyEvent.newValue === 'number'
? propertyEvent.newValue
: undefined
})
break
case 'showAdvanced':
vueNodeData.set(nodeId, {
...currentData,
showAdvanced: Boolean(propertyEvent.newValue)
})
break
}
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(String(slotErrorsEvent.nodeId))
},
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(slotLinksEvent.nodeId))
const { property, newValue } = propertyEvent
switch (property) {
case 'title':
vueNodeData.set(nodeId, {
...currentData,
title: String(newValue)
})
break
case 'flags.collapsed':
case 'flags.ghost':
case 'flags.pinned': {
const flagName = property.split('.')[1] as
| 'collapsed'
| 'ghost'
| 'pinned'
vueNodeData.set(nodeId, {
...currentData,
flags: { ...currentData.flags, [flagName]: Boolean(newValue) }
})
break
}
case 'mode':
vueNodeData.set(nodeId, {
...currentData,
mode: typeof newValue === 'number' ? newValue : 0
})
break
case 'color':
case 'bgcolor':
vueNodeData.set(nodeId, {
...currentData,
[property]: typeof newValue === 'string' ? newValue : undefined
})
break
case 'shape':
vueNodeData.set(nodeId, {
...currentData,
shape: typeof newValue === 'number' ? newValue : undefined
})
break
case 'showAdvanced':
vueNodeData.set(nodeId, {
...currentData,
showAdvanced: Boolean(newValue)
})
break
}
}
graph.onTrigger = (event: LGraphTriggerEvent) => {
switch (event.type) {
case 'node:property:changed':
triggerHandlers['node:property:changed'](event)
handlePropertyChanged(event)
break
case 'node:slot-errors:changed':
triggerHandlers['node:slot-errors:changed'](event)
refreshNodeSlots(String(event.nodeId))
break
case 'node:slot-links:changed':
triggerHandlers['node:slot-links:changed'](event)
if (event.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(String(event.nodeId))
}
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
// Return cleanup function
return createCleanupFunction(
originalOnNodeAdded || undefined,

View File

@@ -1,15 +1,19 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
LLink
LLink,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { useLinkStore } from '@/stores/linkStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
@@ -19,6 +23,39 @@ import {
import { test } from './__fixtures__/testExtensions'
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => {
const createLink = vi.fn()
const deleteLink = vi.fn()
const createNode = vi.fn()
const deleteNode = vi.fn()
const moveNode = vi.fn()
const resizeNode = vi.fn()
const setNodeZIndex = vi.fn()
const createReroute = vi.fn()
const deleteReroute = vi.fn()
const moveReroute = vi.fn()
const bringNodeToFront = vi.fn()
const setSource = vi.fn()
const setActor = vi.fn()
return {
useLayoutMutations: () => ({
createLink,
deleteLink,
createNode,
deleteNode,
moveNode,
resizeNode,
setNodeZIndex,
createReroute,
deleteReroute,
moveReroute,
bringNodeToFront,
setSource,
setActor
})
}
})
function swapNodes(nodes: LGraphNode[]) {
const firstNode = nodes[0]
const lastNode = nodes[nodes.length - 1]
@@ -39,6 +76,46 @@ class DummyNode extends LGraphNode {
}
}
function createNumberNode(title: string): LGraphNode {
const node = new LGraphNode(title)
node.addOutput('out', 'number')
node.addInput('in', 'number')
return node
}
function buildLinkTopology(graph: LGraph): {
disconnectedLinkId: number
floatingLinkId: number
linkedNodeId: NodeId
rerouteId: number
} {
const source = createNumberNode('source')
const floatingTarget = createNumberNode('floating-target')
const linkedTarget = createNumberNode('linked-target')
graph.add(source)
graph.add(floatingTarget)
graph.add(linkedTarget)
source.connect(0, floatingTarget, 0)
source.connect(0, linkedTarget, 0)
const linkToDisconnect = graph.getLink(floatingTarget.inputs[0].link)
if (!linkToDisconnect) throw new Error('Expected link to disconnect')
const reroute = graph.createReroute([120, 80], linkToDisconnect)
graph.addFloatingLink(linkToDisconnect.toFloating('output', reroute.id))
const floatingLinkId = [...graph.floatingLinks.keys()][0]
if (floatingLinkId == null) throw new Error('Expected floating link')
return {
disconnectedLinkId: linkToDisconnect.id,
floatingLinkId,
linkedNodeId: linkedTarget.id,
rerouteId: reroute.id
}
}
describe('LGraph', () => {
it('should serialize deterministic node order', async () => {
LiteGraph.registerNodeType('dummy', DummyNode)
@@ -88,6 +165,39 @@ describe('LGraph', () => {
const fromOldSchema = new LGraph(oldSchemaGraph)
expect(fromOldSchema).toMatchSnapshot('oldSchemaGraph')
})
it('round-trips v0.4 link parent extensions and reroutes through configure', () => {
const source = createNumberNode('source')
const target = createNumberNode('target')
const graph = createGraph(source, target)
const link = source.connect(0, target, 0)
if (!link) throw new Error('Expected link')
const reroute = graph.createReroute([80, 40], link)
const serialized04 = graph.serialize()
const restored = new LGraph(serialized04)
const restoredLink = restored.getLink(link.id)
if (!restoredLink) throw new Error('Expected restored link')
expect(restoredLink.parentId).toBe(reroute.id)
expect(restored.reroutes.size).toBe(1)
expect(restored.reroutes.get(reroute.id)?.linkIds.has(link.id)).toBe(true)
})
it('round-trips v1 serialisable links/floating/reroutes through configure', () => {
const graph = new LGraph()
const { floatingLinkId, rerouteId, linkedNodeId } = buildLinkTopology(graph)
const serialisedV1 = graph.asSerialisable()
const restored = new LGraph(serialisedV1)
const linkedInputLinkId = restored.getNodeById(linkedNodeId)?.inputs[0].link
expect(linkedInputLinkId).toBeDefined()
expect(restored.getLink(linkedInputLinkId)).toBeDefined()
expect(restored.getReroute(rerouteId)).toBeDefined()
expect(restored.floatingLinks.get(floatingLinkId)).toBeDefined()
})
})
describe('Floating Links / Reroutes', () => {
@@ -184,6 +294,256 @@ describe('Floating Links / Reroutes', () => {
})
})
describe('LinkStore Lifecycle Rehydration', () => {
it('tracks links, floating links, and reroutes after configure', () => {
const graph = new LGraph()
const { floatingLinkId, linkedNodeId, rerouteId } = buildLinkTopology(graph)
const serialised = graph.asSerialisable()
const restored = new LGraph(serialised)
const linkedInput = restored.getNodeById(linkedNodeId)?.inputs[0]
expect(linkedInput?.link).toBeDefined()
const linkedInputLink = restored.getLink(linkedInput!.link!)
expect(linkedInputLink).toBeDefined()
const linkStore = useLinkStore()
const topology = linkStore.getTopology(restored.linkStoreKey)
expect(topology.links.size).toBe(restored.links.size)
expect(topology.floatingLinks.size).toBe(restored.floatingLinks.size)
expect(topology.reroutes.size).toBe(restored.reroutes.size)
expect(
linkStore.getFloatingLink(restored.linkStoreKey, floatingLinkId)
).toBeDefined()
expect(linkStore.getReroute(restored.linkStoreKey, rerouteId)).toBeDefined()
expect(linkStore.getLink(restored.linkStoreKey, linkedInput!.link!)).toBe(
linkedInputLink
)
})
it('clears and rehydrates the store on graph.clear()', () => {
const graph = new LGraph()
buildLinkTopology(graph)
const linkStore = useLinkStore()
const topologyBefore = linkStore.getTopology(graph.linkStoreKey)
expect(topologyBefore.links.size).toBeGreaterThan(0)
expect(topologyBefore.floatingLinks.size).toBeGreaterThan(0)
expect(topologyBefore.reroutes.size).toBeGreaterThan(0)
graph.clear()
const topologyAfter = linkStore.getTopology(graph.linkStoreKey)
expect(topologyAfter.links.size).toBe(0)
expect(topologyAfter.floatingLinks.size).toBe(0)
expect(topologyAfter.reroutes.size).toBe(0)
})
it('preserves root/subgraph store isolation after round-trip', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const root = new LGraph()
const rootTopology = buildLinkTopology(root)
const subgraph = root.createSubgraph(createTestSubgraphData())
const subgraphSource = createNumberNode('subgraph-source')
const subgraphTarget = createNumberNode('subgraph-target')
subgraph.add(subgraphSource)
subgraph.add(subgraphTarget)
subgraphSource.connect(0, subgraphTarget, 0)
root.add(createTestSubgraphNode(subgraph, { pos: [500, 200] }))
const serialised = root.asSerialisable()
const restoredRoot = new LGraph(serialised)
const restoredSubgraph = [...restoredRoot.subgraphs.values()][0]
if (!restoredSubgraph) throw new Error('Expected restored subgraph')
const subgraphLinkId = [...restoredSubgraph.links.keys()][0]
const linkStore = useLinkStore()
expect(
linkStore.getFloatingLink(
restoredRoot.linkStoreKey,
rootTopology.floatingLinkId
)
).toBeDefined()
expect(
linkStore.getReroute(restoredRoot.linkStoreKey, rootTopology.rerouteId)
).toBeDefined()
expect(
linkStore.getLink(restoredRoot.linkStoreKey, subgraphLinkId)
).toBeUndefined()
expect(
linkStore.getLink(restoredSubgraph.linkStoreKey, subgraphLinkId)
).toBeDefined()
expect(
linkStore.getTopology(restoredSubgraph.linkStoreKey).floatingLinks.size
).toBe(0)
})
})
describe('LinkStore Read-Through Projection', () => {
it('reads normal links from the projected store map', () => {
const graph = new LGraph()
const { linkedNodeId } = buildLinkTopology(graph)
const linkId = graph.getNodeById(linkedNodeId)?.inputs[0].link
if (linkId == null) throw new Error('Expected linked input link')
const linkStore = useLinkStore()
const projectedLinks = new Map(graph.links)
linkStore.rehydrate(graph.linkStoreKey, {
links: projectedLinks,
floatingLinks: graph.floatingLinks,
reroutes: graph.reroutes
})
graph.links.clear()
expect(graph.getLink(linkId)).toBe(projectedLinks.get(linkId))
expect(graph.links.get(linkId)).toBeUndefined()
})
it('reads reroutes from the projected store map', () => {
const graph = new LGraph()
const { rerouteId } = buildLinkTopology(graph)
const linkStore = useLinkStore()
const projectedReroutes = new Map(graph.reroutes)
linkStore.rehydrate(graph.linkStoreKey, {
links: graph.links,
floatingLinks: graph.floatingLinks,
reroutes: projectedReroutes
})
graph.reroutes.clear()
expect(graph.getReroute(rerouteId)).toBe(projectedReroutes.get(rerouteId))
expect(graph.reroutes.get(rerouteId)).toBeUndefined()
})
it('keeps floating-link reads explicit through floating projection', () => {
const graph = new LGraph()
const { floatingLinkId } = buildLinkTopology(graph)
const linkStore = useLinkStore()
const floatingLink = linkStore.getFloatingLink(
graph.linkStoreKey,
floatingLinkId
)
if (!floatingLink) throw new Error('Expected floating link projection')
expect(graph.getLink(floatingLinkId)).not.toBe(floatingLink)
expect(linkStore.getFloatingLink(graph.linkStoreKey, floatingLinkId)).toBe(
floatingLink
)
})
})
describe('Disconnect/Remove Characterization', () => {
it('graph.removeLink preserves disconnect callback ordering parity', () => {
const graph = new LGraph()
const sourceNode = createNumberNode('source')
const targetNode = createNumberNode('target')
graph.add(sourceNode)
graph.add(targetNode)
const link = sourceNode.connect(0, targetNode, 0)
if (!link) throw new Error('Expected link')
const callbackOrder: string[] = []
targetNode.onConnectionsChange = (
slotType,
slotIndex,
connected,
linkInfo
) => {
if (!linkInfo) throw new Error('Expected link info')
callbackOrder.push(`target:${slotType}:${slotIndex}:${connected}`)
expect(slotType).toBe(NodeSlotType.INPUT)
expect(slotIndex).toBe(0)
expect(connected).toBe(false)
expect(linkInfo.id).toBe(link.id)
}
sourceNode.onConnectionsChange = (
slotType,
slotIndex,
connected,
linkInfo
) => {
if (!linkInfo) throw new Error('Expected link info')
callbackOrder.push(`source:${slotType}:${slotIndex}:${connected}`)
expect(slotType).toBe(NodeSlotType.OUTPUT)
expect(slotIndex).toBe(0)
expect(connected).toBe(false)
expect(linkInfo.id).toBe(link.id)
}
graph.removeLink(link.id)
expect(callbackOrder).toEqual([
`target:${NodeSlotType.INPUT}:0:false`,
`source:${NodeSlotType.OUTPUT}:0:false`
])
expect(graph.getLink(link.id)).toBeUndefined()
expect(targetNode.inputs[0].link).toBeNull()
expect(sourceNode.outputs[0].links).toEqual([])
})
it('removeLink retains floating/reroute cleanup invariants', () => {
const graph = new LGraph()
const { disconnectedLinkId, floatingLinkId, rerouteId } =
buildLinkTopology(graph)
graph.removeLink(disconnectedLinkId)
const linkStore = useLinkStore()
expect(graph.getLink(disconnectedLinkId)).toBeUndefined()
expect(
linkStore.getLink(graph.linkStoreKey, disconnectedLinkId)
).toBeUndefined()
expect(graph.getReroute(rerouteId)).toBeDefined()
expect(linkStore.getReroute(graph.linkStoreKey, rerouteId)).toBeDefined()
expect(graph.floatingLinks.has(floatingLinkId)).toBe(true)
expect(
linkStore.getFloatingLink(graph.linkStoreKey, floatingLinkId)
).toBeDefined()
})
})
describe('Connect Characterization', () => {
it('connect with reroute keeps floating cleanup invariants', () => {
const graph = new LGraph()
const { floatingLinkId, rerouteId } = buildLinkTopology(graph)
const sourceNode = createNumberNode('new-source')
const targetNode = createNumberNode('new-target')
graph.add(sourceNode)
graph.add(targetNode)
const rerouteBeforeConnect = graph.getReroute(rerouteId)
if (!rerouteBeforeConnect) throw new Error('Expected reroute')
expect(rerouteBeforeConnect.floatingLinkIds.has(floatingLinkId)).toBe(true)
const link = sourceNode.connect(0, targetNode, 0, rerouteId)
if (!link) throw new Error('Expected link')
const rerouteAfterConnect = graph.getReroute(rerouteId)
if (!rerouteAfterConnect) throw new Error('Expected reroute after connect')
const linkStore = useLinkStore()
expect(graph.getLink(link.id)).toBe(link)
expect(linkStore.getLink(graph.linkStoreKey, link.id)).toBe(link)
expect(rerouteAfterConnect.linkIds.has(link.id)).toBe(true)
expect(rerouteAfterConnect.floating).toBeUndefined()
expect(rerouteAfterConnect.floatingLinkIds.has(floatingLinkId)).toBe(false)
expect(graph.floatingLinks.has(floatingLinkId)).toBe(false)
expect(
linkStore.getFloatingLink(graph.linkStoreKey, floatingLinkId)
).toBeUndefined()
})
})
describe('Graph Clearing and Callbacks', () => {
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
expect
@@ -494,6 +854,33 @@ describe('ensureGlobalIdUniqueness', () => {
expect(link.origin_id).not.toBe(rootNode.id)
})
it('patches floating link origin_id and target_id after reassignment', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
const rootNode = new DummyNode()
rootGraph.add(rootNode)
const subNodeA = new DummyNode()
subNodeA.id = rootNode.id
subgraph._nodes.push(subNodeA)
subgraph._nodes_by_id[subNodeA.id] = subNodeA
const subNodeB = new DummyNode()
subNodeB.id = 777
subgraph._nodes.push(subNodeB)
subgraph._nodes_by_id[subNodeB.id] = subNodeB
const floatingLink = new LLink(9, 'number', subNodeA.id, 0, subNodeB.id, 0)
subgraph.addFloatingLink(floatingLink)
rootGraph.ensureGlobalIdUniqueness()
expect(floatingLink.origin_id).toBe(subNodeA.id)
expect(floatingLink.target_id).toBe(subNodeB.id)
expect(floatingLink.origin_id).not.toBe(rootNode.id)
})
it('detects collisions with reserved (not-yet-created) node IDs', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
@@ -530,6 +917,42 @@ describe('ensureGlobalIdUniqueness', () => {
})
describe('Subgraph Unpacking', () => {
function installSubgraphNodeRegistration(rootGraph: LGraph): () => void {
const listener = (event: CustomEvent<{ subgraph: Subgraph }>): void => {
const { subgraph } = event.detail
class RuntimeSubgraphNode extends SubgraphNode {
constructor(title?: string) {
super(rootGraph, subgraph, {
id: ++rootGraph.last_node_id,
type: subgraph.id,
title,
pos: [0, 0],
size: [140, 80],
inputs: [],
outputs: [],
properties: {},
flags: {},
mode: 0,
order: 0
})
}
}
LiteGraph.registerNodeType(subgraph.id, RuntimeSubgraphNode)
}
rootGraph.events.addEventListener('subgraph-created', listener)
return () =>
rootGraph.events.removeEventListener('subgraph-created', listener)
}
function getRequiredNodeByTitle(graph: LGraph, title: string): LGraphNode {
const node = graph.nodes.find((candidate) => candidate.title === title)
if (!node) throw new Error(`Expected node titled ${title}`)
return node
}
class TestNode extends LGraphNode {
constructor(title?: string) {
super(title ?? 'TestNode')
@@ -635,6 +1058,117 @@ describe('Subgraph Unpacking', () => {
expect(unpackedTarget.inputs[1].link).toBeNull()
})
it('preserves boundary input reroute parent remap across convert and unpack', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
registerTestNodes()
const rootGraph = new LGraph()
const cleanupRegistration = installSubgraphNodeRegistration(rootGraph)
try {
const externalSource = LiteGraph.createNode(
'test/TestNode',
'external-source'
)
const boundaryTarget = LiteGraph.createNode(
'test/TestNode',
'boundary-target'
)
if (!externalSource || !boundaryTarget)
throw new Error('Expected test nodes')
rootGraph.add(externalSource)
rootGraph.add(boundaryTarget)
const boundaryLink = externalSource.connect(0, boundaryTarget, 0)
if (!boundaryLink) throw new Error('Expected boundary link')
const reroute = rootGraph.createReroute([120, 40], boundaryLink)
expect(boundaryLink.parentId).toBe(reroute.id)
const { node: subgraphNode } = rootGraph.convertToSubgraph(
new Set([boundaryTarget])
)
const convertedBoundaryLinkId = subgraphNode.inputs[0].link
if (convertedBoundaryLinkId == null)
throw new Error('Expected converted boundary input link')
const convertedBoundaryLink = rootGraph.getLink(convertedBoundaryLinkId)
if (!convertedBoundaryLink)
throw new Error('Expected converted boundary input link instance')
expect(convertedBoundaryLink.parentId).toBe(reroute.id)
rootGraph.unpackSubgraph(subgraphNode)
const unpackedTarget = getRequiredNodeByTitle(
rootGraph,
'boundary-target'
)
const unpackedLink = rootGraph.getLink(unpackedTarget.inputs[0].link)
if (!unpackedLink)
throw new Error('Expected unpacked boundary input link')
expect(unpackedLink.origin_id).toBe(externalSource.id)
expect(unpackedLink.target_id).toBe(unpackedTarget.id)
expect(unpackedLink.parentId).toBe(reroute.id)
} finally {
cleanupRegistration()
}
})
it('preserves boundary output reroute parent remap across convert and unpack', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
registerTestNodes()
const rootGraph = new LGraph()
const cleanupRegistration = installSubgraphNodeRegistration(rootGraph)
try {
const boundarySource = LiteGraph.createNode(
'test/TestNode',
'boundary-source'
)
const externalTarget = LiteGraph.createNode(
'test/TestNode',
'external-target'
)
if (!boundarySource || !externalTarget)
throw new Error('Expected test nodes')
rootGraph.add(boundarySource)
rootGraph.add(externalTarget)
const boundaryLink = boundarySource.connect(0, externalTarget, 0)
if (!boundaryLink) throw new Error('Expected boundary link')
const reroute = rootGraph.createReroute([180, 80], boundaryLink)
expect(boundaryLink.parentId).toBe(reroute.id)
const { node: subgraphNode } = rootGraph.convertToSubgraph(
new Set([boundarySource])
)
const convertedBoundaryLinkId = subgraphNode.outputs[0].links?.[0]
if (convertedBoundaryLinkId == null)
throw new Error('Expected converted boundary output link')
const convertedBoundaryLink = rootGraph.getLink(convertedBoundaryLinkId)
if (!convertedBoundaryLink)
throw new Error('Expected converted boundary output link instance')
expect(convertedBoundaryLink.parentId).toBe(reroute.id)
rootGraph.unpackSubgraph(subgraphNode)
const unpackedSource = getRequiredNodeByTitle(
rootGraph,
'boundary-source'
)
const unpackedLinkId = unpackedSource.outputs[0].links?.[0]
const unpackedLink = rootGraph.getLink(unpackedLinkId)
if (!unpackedLink)
throw new Error('Expected unpacked boundary output link')
expect(unpackedLink.origin_id).toBe(unpackedSource.id)
expect(unpackedLink.target_id).toBe(externalTarget.id)
expect(unpackedLink.parentId).toBe(reroute.id)
} finally {
cleanupRegistration()
}
})
it('keeps subgraph definition when unpacking one instance while another remains', () => {
const rootGraph = new LGraph()
const subgraph = createSubgraphOnGraph(rootGraph)
@@ -656,3 +1190,61 @@ describe('Subgraph Unpacking', () => {
expect(definitionIds).toContain(subgraph.id)
})
})
describe('Subgraph Layout Integration', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
function createSubgraphWithIO(rootGraph: LGraph) {
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
subgraph.addInput('in_0', 'number')
subgraph.addOutput('out_0', 'number')
const innerNode = new LGraphNode('InnerNode')
innerNode.addInput('in', 'number')
innerNode.addOutput('out', 'number')
subgraph.add(innerNode)
return { subgraph, innerNode }
}
it('calls layoutMutations.createLink when connectSubgraphInputSlot is called', () => {
const rootGraph = new LGraph()
const { subgraph, innerNode } = createSubgraphWithIO(rootGraph)
const subgraphInput = subgraph.inputs[0]
const link = subgraph.connectSubgraphInputSlot(subgraphInput, innerNode, 0)
const mutations = useLayoutMutations()
expect(mutations.createLink).toHaveBeenCalledWith(
link.id,
subgraphInput.parent.id,
0,
innerNode.id,
0
)
})
it('calls layoutMutations.createLink when connectSubgraphOutputSlot is called', () => {
const rootGraph = new LGraph()
const { subgraph, innerNode } = createSubgraphWithIO(rootGraph)
const subgraphOutput = subgraph.outputs[0]
const link = subgraph.connectSubgraphOutputSlot(
innerNode,
0,
subgraphOutput
)
const mutations = useLayoutMutations()
expect(mutations.createLink).toHaveBeenCalledWith(
link.id,
innerNode.id,
0,
subgraphOutput.parent.id,
0
)
})
})

View File

@@ -4,11 +4,16 @@ import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import {
normalizeLegacySlotIdentity,
resolveCanonicalSlotName
} from '@/lib/litegraph/src/utils/slotIdentity'
import { commonType, isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { useLinkStore } from '@/stores/linkStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -24,6 +29,8 @@ import { MapProxyHandler } from './MapProxyHandler'
import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
import { graphLifecycleEventDispatcher } from './infrastructure/GraphLifecycleEventDispatcher'
import { graphPersistenceAdapter } from './infrastructure/GraphPersistenceAdapter'
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
import type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
import type {
@@ -33,6 +40,7 @@ import type {
IContextMenuValue,
INodeInputSlot,
INodeOutputSlot,
ISlotType,
LinkNetwork,
LinkSegment,
MethodNames,
@@ -58,9 +66,10 @@ import {
mapSubgraphInputsAndLinks,
mapSubgraphOutputsAndLinks,
multiClone,
subgraphBoundaryAdapter,
splitPositionables
} from './subgraph/subgraphUtils'
import { Alignment, LGraphEventMode } from './types/globalEnums'
import { Alignment, LGraphEventMode, NodeSlotType } from './types/globalEnums'
import type {
LGraphTriggerAction,
LGraphTriggerEvent,
@@ -78,10 +87,7 @@ import type {
} from './types/serialisation'
import { getAllNestedItems } from './utils/collections'
export type {
LGraphTriggerAction,
LGraphTriggerParam
} from './types/graphTriggers'
export type { LGraphTriggerParam } from './types/graphTriggers'
export type RendererType = 'LG' | 'Vue'
@@ -269,7 +275,16 @@ export class LGraph
/** Internal only. Not required for serialisation; calculated on deserialise. */
private _lastFloatingLinkId: number = 0
/** Stable instance key for the link store — never changes once created. */
readonly linkStoreKey: UUID = createUuidv4()
private _linkStoreCache?: ReturnType<typeof useLinkStore>
private get linkStore() {
return (this._linkStoreCache ??= useLinkStore())
}
private readonly floatingLinksInternal: Map<LinkId, LLink> = new Map()
get floatingLinks(): ReadonlyMap<LinkId, LLink> {
return this.floatingLinksInternal
}
@@ -359,6 +374,7 @@ export class LGraph
usePromotionStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}
this.linkStore.clearGraph(this.linkStoreKey)
this.id = zeroUuid
this.revision = 0
@@ -393,6 +409,7 @@ export class LGraph
this._links.clear()
this.reroutes.clear()
this.floatingLinksInternal.clear()
this.rehydrateLinkStore()
this._lastFloatingLinkId = 0
@@ -429,6 +446,14 @@ export class LGraph
this.canvasAction((c) => c.clear())
}
private rehydrateLinkStore(): void {
this.linkStore.rehydrate(this.linkStoreKey, {
links: this._links,
floatingLinks: this.floatingLinksInternal,
reroutes: this.reroutesInternal
})
}
get subgraphs(): Map<UUID, Subgraph> {
return this.rootGraph._subgraphs
}
@@ -1446,7 +1471,7 @@ export class LGraph
getLink(id: null | undefined): undefined
getLink(id: LinkId | null | undefined): LLink | undefined
getLink(id: LinkId | null | undefined): LLink | undefined {
return id == null ? undefined : this._links.get(id)
return this.linkStore.getLink(this.linkStoreKey, id)
}
/**
@@ -1457,7 +1482,7 @@ export class LGraph
getReroute(id: null | undefined): undefined
getReroute(id: RerouteId | null | undefined): Reroute | undefined
getReroute(id: RerouteId | null | undefined): Reroute | undefined {
return id == null ? undefined : this.reroutes.get(id)
return this.linkStore.getReroute(this.linkStoreKey, id)
}
/**
@@ -1605,9 +1630,232 @@ export class LGraph
if (!link) return
const node = this.getNodeById(link.target_id)
node?.disconnectInput(link.target_slot, false)
if (node?.disconnectInput(link.target_slot, false)) {
return
}
link.disconnect(this)
this.disconnectLink(link)
}
disconnectLink(link: LLink, keepReroutes?: 'input' | 'output'): void {
link.disconnect(this, keepReroutes)
}
private finalizeConnectedLink(link: LLink): void {
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) reroute.floating = undefined
reroute._dragging = undefined
}
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const floatingLinkId of lastReroute.floatingLinkIds) {
const floatingLink = this.floatingLinks.get(floatingLinkId)
if (floatingLink?.parentId === lastReroute.id) {
this.removeFloatingLink(floatingLink)
}
}
}
this._version++
}
private _createAndRegisterLink(
type: ISlotType,
originId: NodeId,
originSlot: number,
targetId: NodeId,
targetSlot: number,
afterRerouteId?: RerouteId
): LLink {
const link = new LLink(
++this.state.lastLinkId,
type,
originId,
originSlot,
targetId,
targetSlot,
afterRerouteId
)
this._links.set(link.id, link)
const layoutMutations = useLayoutMutations()
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createLink(
link.id,
originId,
originSlot,
targetId,
targetSlot
)
return link
}
/**
* Always returns a valid LLink — callers rely on non-nullable return.
*
* Note: This method does NOT dispatch `dispatchConnectNodePair`.
* Callers (e.g. `LGraphNode.connectSlots`) are responsible for dispatching
* connection callbacks after this returns.
*/
connectSlots(
sourceNode: LGraphNode,
outputIndex: number,
targetNode: LGraphNode,
inputIndex: number,
afterRerouteId?: RerouteId
): LLink {
const output = sourceNode.outputs[outputIndex]
const input = targetNode.inputs[inputIndex]
const maybeCommonType =
input.type && output.type && commonType(input.type, output.type)
const link = this._createAndRegisterLink(
maybeCommonType || input.type || output.type,
sourceNode.id,
outputIndex,
targetNode.id,
inputIndex,
afterRerouteId
)
output.links ??= []
output.links.push(link.id)
input.link = link.id
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph: this,
nodeId: targetNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
linkId: link.id,
hasWidget: !!input.widget
})
this.finalizeConnectedLink(link)
return link
}
connectSubgraphInputSlot(
subgraphInput: SubgraphInput,
targetNode: LGraphNode,
targetSlotIndex: number,
afterRerouteId?: RerouteId
): LLink {
const targetInput = targetNode.inputs[targetSlotIndex]
const subgraphInputIndex = subgraphInput.parent.slots.indexOf(subgraphInput)
const link = this._createAndRegisterLink(
targetInput.type,
subgraphInput.parent.id,
subgraphInputIndex,
targetNode.id,
targetSlotIndex,
afterRerouteId
)
subgraphInput.linkIds.push(link.id)
targetInput.link = link.id
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph: this,
nodeId: targetNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: targetSlotIndex,
connected: true,
linkId: link.id,
hasWidget: !!targetInput.widget
})
this.finalizeConnectedLink(link)
return link
}
connectSubgraphOutputSlot(
sourceNode: LGraphNode,
sourceSlotIndex: number,
subgraphOutput: SubgraphOutput,
afterRerouteId?: RerouteId
): LLink {
const sourceOutput = sourceNode.outputs[sourceSlotIndex]
const subgraphOutputIndex =
subgraphOutput.parent.slots.indexOf(subgraphOutput)
const link = this._createAndRegisterLink(
sourceOutput.type,
sourceNode.id,
sourceSlotIndex,
subgraphOutput.parent.id,
subgraphOutputIndex,
afterRerouteId
)
subgraphOutput.linkIds[0] = link.id
sourceOutput.links ??= []
sourceOutput.links.push(link.id)
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph: this,
nodeId: sourceNode.id,
slotType: NodeSlotType.OUTPUT,
slotIndex: sourceSlotIndex,
connected: true,
linkId: link.id,
hasWidget: false
})
this.finalizeConnectedLink(link)
return link
}
// Versioning: `disconnectLink` / `link.disconnect()` does not increment
// `_version`. Each disconnect method is responsible for its own increment.
disconnectSubgraphInputLink(
subgraphInput: SubgraphInput,
targetNode: LGraphNode,
targetSlotIndex: number,
link: LLink | undefined
): void {
const targetInput = targetNode.inputs[targetSlotIndex]
if (targetInput._floatingLinks?.size) {
for (const floatingLink of targetInput._floatingLinks) {
this.removeFloatingLink(floatingLink)
}
}
targetInput.link = null
if (!link) return
this.disconnectLink(link, 'output')
this._version++
const index = subgraphInput.linkIds.indexOf(link.id)
if (index === -1) {
console.warn(
'disconnectSubgraphInputLink: link ID not found in subgraph input',
link.id
)
return
}
subgraphInput.linkIds.splice(index, 1)
}
disconnectSubgraphOutputLink(
subgraphOutput: SubgraphOutput,
sourceNode: LGraphNode,
sourceSlotIndex: number,
link: LLink
): void {
const sourceOutput = sourceNode.outputs[sourceSlotIndex]
this.disconnectLink(link, 'input')
this._version++
if (sourceOutput.links) {
sourceOutput.links = sourceOutput.links.filter((id) => id !== link.id)
}
const subgraphLinkIndex = subgraphOutput.linkIds.indexOf(link.id)
if (subgraphLinkIndex !== -1) {
subgraphOutput.linkIds.splice(subgraphLinkIndex, 1)
}
}
/**
@@ -1798,7 +2046,7 @@ export class LGraph
// Special handling: Subgraph input node
i++
if (link.origin_id === SUBGRAPH_INPUT_ID) {
if (link.originIsIoNode) {
link.target_id = subgraphNode.id
link.target_slot = i - 1
if (subgraphInput instanceof SubgraphInput) {
@@ -1812,7 +2060,7 @@ export class LGraph
}
for (const resolved of others) {
resolved.link.disconnect(this)
this.disconnectLink(resolved.link)
}
continue
}
@@ -1839,7 +2087,7 @@ export class LGraph
for (const connection of connections) {
const { input, inputNode, link, subgraphOutput } = connection
// Special handling: Subgraph output node
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
if (link.targetIsIoNode) {
link.origin_id = subgraphNode.id
link.origin_slot = i - 1
this.links.set(link.id, link)
@@ -1850,7 +2098,7 @@ export class LGraph
link.parentId
)
} else {
throw new TypeError('Subgraph input node is not a SubgraphInput')
throw new TypeError('Subgraph output node is not a SubgraphOutput')
}
continue
}
@@ -2011,16 +2259,20 @@ export class LGraph
}[] = []
for (const [, link] of subgraphNode.subgraph._links) {
let externalParentId: RerouteId | undefined
if (link.origin_id === SUBGRAPH_INPUT_ID) {
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
if (!outerLinkId) {
if (link.originIsIoNode) {
const endpoint = subgraphBoundaryAdapter.remapInputBoundaryForUnpack(
link,
subgraphNode,
this.links
)
if (!endpoint) {
console.error('Missing Link ID when unpacking')
continue
}
const outerLink = this.links[outerLinkId]
link.origin_id = outerLink.origin_id
link.origin_slot = outerLink.origin_slot
externalParentId = outerLink.parentId
link.origin_id = endpoint.originId
link.origin_slot = endpoint.originSlot
externalParentId = endpoint.externalParentId
} else {
const origin_id = nodeIdMap.get(link.origin_id)
if (!origin_id) {
@@ -2029,22 +2281,37 @@ export class LGraph
}
link.origin_id = origin_id
}
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
[]) {
const sublink = this.links[linkId]
if (link.targetIsIoNode) {
const outputEndpoints =
subgraphBoundaryAdapter.resolveOutputBoundaryForUnpack(
link,
subgraphNode,
this.links
)
if (outputEndpoints.length === 0) {
console.error('Missing Link ID when unpacking')
continue
}
for (const endpoint of outputEndpoints) {
newLinks.push({
oid: link.origin_id,
oslot: link.origin_slot,
tid: sublink.target_id,
tslot: sublink.target_slot,
tid: endpoint.targetId,
tslot: endpoint.targetSlot,
id: link.id,
iparent: link.parentId,
eparent: sublink.parentId,
eparent: endpoint.externalParentId,
externalFirst: true
})
sublink.parentId = undefined
}
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
[]) {
const sublink = this.links.get(linkId)
if (sublink) sublink.parentId = undefined
}
continue
} else {
const target_id = nodeIdMap.get(link.target_id)
@@ -2403,59 +2670,12 @@ export class LGraph
this._configureBase(data)
let reroutes: SerialisableReroute[] | undefined
// TODO: Determine whether this should this fall back to 0.4.
if (data.version === 0.4) {
const { extra } = data
// Deprecated - old schema version, links are arrays
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.createFromArray(linkData)
this._links.set(link.id, link)
}
}
// #region `extra` embeds for v0.4
// LLink parentIds
if (Array.isArray(extra?.linkExtensions)) {
for (const linkEx of extra.linkExtensions) {
const link = this._links.get(linkEx.id)
if (link) link.parentId = linkEx.parentId
}
}
// Reroutes
reroutes = extra?.reroutes
// #endregion `extra` embeds for v0.4
} else {
// New schema - one version so far, no check required.
// State - use max to prevent ID collisions across root and subgraphs
if (data.state) {
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } =
data.state
const { state } = this
if (lastGroupId != null)
state.lastGroupId = Math.max(state.lastGroupId, lastGroupId)
if (lastLinkId != null)
state.lastLinkId = Math.max(state.lastLinkId, lastLinkId)
if (lastNodeId != null)
state.lastNodeId = Math.max(state.lastNodeId, lastNodeId)
if (lastRerouteId != null)
state.lastRerouteId = Math.max(state.lastRerouteId, lastRerouteId)
}
// Links
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.create(linkData)
this._links.set(link.id, link)
}
}
reroutes = data.reroutes
const { links, reroutes } = graphPersistenceAdapter.toConfiguredTopology(
data,
this.state
)
for (const link of links) {
this._links.set(link.id, link)
}
// Reroutes
@@ -2578,6 +2798,7 @@ export class LGraph
this.setDirtyCanvas(true, true)
return error
} finally {
this.rehydrateLinkStore()
this.events.dispatch('configured')
}
}
@@ -2620,8 +2841,14 @@ export class LGraph
}
if (remappedIds.size > 0) {
patchLinkNodeIds(graph._links, remappedIds)
patchLinkNodeIds(graph.floatingLinksInternal, remappedIds)
graphPersistenceAdapter.patchLinkNodeIds(
graph._links.values(),
remappedIds
)
graphPersistenceAdapter.patchLinkNodeIds(
graph.floatingLinksInternal.values(),
remappedIds
)
}
}
}
@@ -2751,8 +2978,11 @@ export class Subgraph
for (const input of inputs) {
const subgraphInput = new SubgraphInput(input, this.inputNode)
this.inputs.push(subgraphInput)
this.events.dispatch('input-added', { input: subgraphInput })
}
normalizeLegacySlotIdentity(this.inputs)
for (const subgraphInput of this.inputs)
this.events.dispatch('input-added', { input: subgraphInput })
}
if (outputs) {
@@ -2760,6 +2990,8 @@ export class Subgraph
for (const output of outputs) {
this.outputs.push(new SubgraphOutput(output, this.outputNode))
}
normalizeLegacySlotIdentity(this.outputs)
}
if (widgets) {
@@ -2798,10 +3030,14 @@ export class Subgraph
this.events.dispatch('adding-input', { name, type })
const id = createUuidv4()
const canonicalName = resolveCanonicalSlotName(this.inputs, name, id)
const input = new SubgraphInput(
{
id: createUuidv4(),
name,
id,
name: canonicalName,
label: canonicalName === name ? undefined : name,
type
},
this.inputNode
@@ -2820,10 +3056,14 @@ export class Subgraph
this.events.dispatch('adding-output', { name, type })
const id = createUuidv4()
const canonicalName = resolveCanonicalSlotName(this.outputs, name, id)
const output = new SubgraphOutput(
{
id: createUuidv4(),
name,
id,
name: canonicalName,
label: canonicalName === name ? undefined : name,
type
},
this.outputNode
@@ -2845,13 +3085,16 @@ export class Subgraph
if (index === -1) throw new Error('Input not found')
const oldName = input.displayName
const canonicalName = resolveCanonicalSlotName(this.inputs, name, input.id)
this.events.dispatch('renaming-input', {
input,
index,
oldName,
newName: name
newName: name,
canonicalName
})
input.name = canonicalName
input.label = name
}
@@ -2865,13 +3108,20 @@ export class Subgraph
if (index === -1) throw new Error('Output not found')
const oldName = output.displayName
const canonicalName = resolveCanonicalSlotName(
this.outputs,
name,
output.id
)
this.events.dispatch('renaming-output', {
output,
index,
oldName,
newName: name
newName: name,
canonicalName
})
output.name = canonicalName
output.label = name
}
@@ -2972,16 +3222,3 @@ export class Subgraph
}
}
}
function patchLinkNodeIds(
links: Map<LinkId, LLink>,
remappedIds: Map<NodeId, NodeId>
): void {
for (const link of links.values()) {
const newOrigin = remappedIds.get(link.origin_id)
if (newOrigin !== undefined) link.origin_id = newOrigin
const newTarget = remappedIds.get(link.target_id)
if (newTarget !== undefined) link.target_id = newTarget
}
}

View File

@@ -99,4 +99,90 @@ describe('remapClipboardSubgraphNodeIds', () => {
[String(remappedInteriorId), 'seed']
])
})
it('preserves non-remapped proxy widget refs and rewrites internal link endpoints', () => {
const rootGraph = new LGraph()
const existingNodeA = new LGraphNode('existing-a')
existingNodeA.id = 1
rootGraph.add(existingNodeA)
const existingNodeB = new LGraphNode('existing-b')
existingNodeB.id = 2
rootGraph.add(existingNodeB)
const subgraphId = createUuidv4()
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
config: {},
name: 'Pasted Subgraph',
inputNode: {
id: -10,
bounding: [0, 0, 10, 10]
},
outputNode: {
id: -20,
bounding: [0, 0, 10, 10]
},
inputs: [],
outputs: [],
widgets: [],
nodes: [
createSerialisedNode(1, 'test/node'),
createSerialisedNode(2, 'test/node')
],
links: [
{
id: 1,
type: '*',
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0
}
],
groups: []
}
const parsed: ClipboardItems = {
nodes: [
createSerialisedNode(99, subgraphId, [
['1', 'seed'],
['external', 'label']
])
],
groups: [],
reroutes: [],
links: [],
subgraphs: [pastedSubgraph]
}
remapClipboardSubgraphNodeIds(parsed, rootGraph)
const remappedSubgraph = parsed.subgraphs?.[0]
if (!remappedSubgraph) throw new Error('Expected remapped subgraph')
const [firstInterior, secondInterior] = remappedSubgraph.nodes ?? []
if (!firstInterior || !secondInterior)
throw new Error('Expected remapped interior nodes')
expect(firstInterior.id).not.toBe(1)
expect(secondInterior.id).not.toBe(2)
const remappedLink = remappedSubgraph.links?.[0]
expect(remappedLink?.origin_id).toBe(firstInterior.id)
expect(remappedLink?.target_id).toBe(secondInterior.id)
const remappedNode = parsed.nodes?.[0]
expect(remappedNode?.properties?.proxyWidgets).toStrictEqual([
[String(firstInterior.id), 'seed'],
['external', 'label']
])
})
})

View File

@@ -30,6 +30,7 @@ import type {
CustomEventDispatcher,
ICustomEventTarget
} from './infrastructure/CustomEventTarget'
import { graphPersistenceAdapter } from './infrastructure/GraphPersistenceAdapter'
import type { LGraphCanvasEventMap } from './infrastructure/LGraphCanvasEventMap'
import { NullGraphError } from './infrastructure/NullGraphError'
import { Rectangle } from './infrastructure/Rectangle'
@@ -8803,55 +8804,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
function patchLinkNodeIds(
links: { origin_id: NodeId; target_id: NodeId }[] | undefined,
remappedIds: Map<NodeId, NodeId>
) {
if (!links?.length) return
for (const link of links) {
const newOriginId = remappedIds.get(link.origin_id)
if (newOriginId !== undefined) link.origin_id = newOriginId
const newTargetId = remappedIds.get(link.target_id)
if (newTargetId !== undefined) link.target_id = newTargetId
}
}
function remapNodeId(
nodeId: string,
remappedIds: Map<NodeId, NodeId>
): NodeId | undefined {
const directMatch = remappedIds.get(nodeId)
if (directMatch !== undefined) return directMatch
if (!/^-?\d+$/.test(nodeId)) return undefined
const numericId = Number(nodeId)
if (!Number.isSafeInteger(numericId)) return undefined
return remappedIds.get(numericId)
}
function remapProxyWidgets(
info: ISerialisedNode,
remappedIds: Map<NodeId, NodeId> | undefined
) {
if (!remappedIds || remappedIds.size === 0) return
const proxyWidgets = info.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return
for (const entry of proxyWidgets) {
if (!Array.isArray(entry)) continue
const [nodeId] = entry
if (typeof nodeId !== 'string' || nodeId === '-1') continue
const remappedNodeId = remapNodeId(nodeId, remappedIds)
if (remappedNodeId !== undefined) entry[0] = String(remappedNodeId)
}
}
/**
* Remaps pasted subgraph interior node IDs that would collide with existing
* node IDs in the root graph. Also patches subgraph link node IDs and
@@ -8899,7 +8851,7 @@ export function remapClipboardSubgraphNodeIds(
}
if (remappedIds.size > 0) {
patchLinkNodeIds(subgraphInfo.links, remappedIds)
graphPersistenceAdapter.patchLinkNodeIds(subgraphInfo.links, remappedIds)
subgraphNodeIdMap.set(subgraphInfo.id, remappedIds)
}
}
@@ -8911,6 +8863,8 @@ export function remapClipboardSubgraphNodeIds(
for (const nodeInfo of allNodeInfo) {
if (typeof nodeInfo.type !== 'string') continue
remapProxyWidgets(nodeInfo, subgraphNodeIdMap.get(nodeInfo.type))
const remappedIds = subgraphNodeIdMap.get(nodeInfo.type)
if (!remappedIds) continue
graphPersistenceAdapter.remapProxyWidgets(nodeInfo, remappedIds)
}
}

View File

@@ -14,6 +14,7 @@ import {
NodeInputSlot,
NodeOutputSlot
} from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { test } from './__fixtures__/testExtensions'
import { createMockLGraphNodeWithArrayBoundingRect } from '@/utils/__tests__/litegraphTestUtils'
@@ -149,6 +150,71 @@ describe('LGraphNode', () => {
})
describe('Disconnect I/O Slots', () => {
test('disconnectInput keeps current callback ordering and payload parity', () => {
const sourceNode = new LGraphNode('SourceNode')
const targetNode = new LGraphNode('TargetNode')
sourceNode.addOutput('Output1', 'number')
targetNode.addInput('Input1', 'number')
const graph = new LGraph()
graph.add(sourceNode)
graph.add(targetNode)
const link = sourceNode.connect(0, targetNode, 0)
if (!link) throw new Error('Expected link')
targetNode.inputs[0].widget = { name: 'in-widget' }
const callbackOrder: string[] = []
graph.onTrigger = (event) => {
if (event.type !== 'node:slot-links:changed') return
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
expect(event.type).toBe('node:slot-links:changed')
expect(event.nodeId).toBe(targetNode.id)
expect(event.slotType).toBe(NodeSlotType.INPUT)
expect(event.slotIndex).toBe(0)
expect(event.connected).toBe(false)
expect(event.linkId).toBe(link.id)
}
targetNode.onConnectionsChange = (
slotType,
slotIndex,
connected,
linkInfo
) => {
if (!linkInfo) throw new Error('Expected link info')
callbackOrder.push(`target:${slotType}:${slotIndex}:${connected}`)
expect(slotType).toBe(NodeSlotType.INPUT)
expect(slotIndex).toBe(0)
expect(connected).toBe(false)
expect(linkInfo.id).toBe(link.id)
}
sourceNode.onConnectionsChange = (
slotType,
slotIndex,
connected,
linkInfo
) => {
if (!linkInfo) throw new Error('Expected link info')
callbackOrder.push(`source:${slotType}:${slotIndex}:${connected}`)
expect(slotType).toBe(NodeSlotType.OUTPUT)
expect(slotIndex).toBe(0)
expect(connected).toBe(false)
expect(linkInfo.id).toBe(link.id)
}
const disconnected = targetNode.disconnectInput(0)
expect(disconnected).toBe(true)
expect(callbackOrder).toEqual([
'trigger:node:slot-links:changed:false',
`target:${NodeSlotType.INPUT}:0:false`,
`source:${NodeSlotType.OUTPUT}:0:false`
])
})
test('should disconnect input correctly', () => {
const node1 = new LGraphNode('SourceNode')
const node2 = new LGraphNode('TargetNode')
@@ -200,6 +266,75 @@ describe('LGraphNode', () => {
expect(alreadyDisconnected).toBe(true)
})
test('disconnectOutput(target) keeps callback ordering and payload parity', () => {
const sourceNode = new LGraphNode('SourceNode')
const targetNode1 = new LGraphNode('TargetNode1')
const targetNode2 = new LGraphNode('TargetNode2')
sourceNode.addOutput('Output1', 'number')
targetNode1.addInput('Input1', 'number')
targetNode2.addInput('Input1', 'number')
const graph = new LGraph()
graph.add(sourceNode)
graph.add(targetNode1)
graph.add(targetNode2)
const targetLink = sourceNode.connect(0, targetNode1, 0)
sourceNode.connect(0, targetNode2, 0)
if (!targetLink) throw new Error('Expected target link')
targetNode1.inputs[0].widget = { name: 'in-widget' }
const callbackOrder: string[] = []
graph.onTrigger = (event) => {
if (event.type !== 'node:slot-links:changed') return
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
expect(event.type).toBe('node:slot-links:changed')
expect(event.nodeId).toBe(targetNode1.id)
expect(event.slotType).toBe(NodeSlotType.INPUT)
expect(event.slotIndex).toBe(0)
expect(event.connected).toBe(false)
expect(event.linkId).toBe(targetLink.id)
}
targetNode1.onConnectionsChange = (
slotType,
slotIndex,
connected,
linkInfo
) => {
if (!linkInfo) throw new Error('Expected link info')
callbackOrder.push(`target:${slotType}:${slotIndex}:${connected}`)
expect(slotType).toBe(NodeSlotType.INPUT)
expect(slotIndex).toBe(0)
expect(connected).toBe(false)
expect(linkInfo.id).toBe(targetLink.id)
}
sourceNode.onConnectionsChange = (
slotType,
slotIndex,
connected,
linkInfo
) => {
if (!linkInfo) throw new Error('Expected link info')
callbackOrder.push(`source:${slotType}:${slotIndex}:${connected}`)
expect(slotType).toBe(NodeSlotType.OUTPUT)
expect(slotIndex).toBe(0)
expect(connected).toBe(false)
expect(linkInfo.id).toBe(targetLink.id)
}
const disconnected = sourceNode.disconnectOutput(0, targetNode1)
expect(disconnected).toBe(true)
expect(callbackOrder).toEqual([
'trigger:node:slot-links:changed:false',
`target:${NodeSlotType.INPUT}:0:false`,
`source:${NodeSlotType.OUTPUT}:0:false`
])
})
test('should disconnect output correctly', () => {
const sourceNode = new LGraphNode('SourceNode')
const targetNode1 = new LGraphNode('TargetNode1')
@@ -310,6 +445,78 @@ describe('LGraphNode', () => {
)
})
describe('Connect Characterization', () => {
test('connect keeps callback ordering and payload parity', () => {
const sourceNode = new LGraphNode('SourceNode')
const targetNode = new LGraphNode('TargetNode')
sourceNode.addOutput('Output1', 'number')
targetNode.addInput('Input1', 'number')
const graph = new LGraph()
graph.add(sourceNode)
graph.add(targetNode)
const callbackOrder: string[] = []
const sourceOutput = sourceNode.outputs[0]
const targetInput = targetNode.inputs[0]
targetInput.widget = { name: 'in-widget' }
graph.onTrigger = (event) => {
if (event.type !== 'node:slot-links:changed') return
callbackOrder.push(`trigger:${event.type}:${event.connected}`)
expect(event.type).toBe('node:slot-links:changed')
expect(event.nodeId).toBe(targetNode.id)
expect(event.slotType).toBe(NodeSlotType.INPUT)
expect(event.slotIndex).toBe(0)
expect(event.connected).toBe(true)
}
sourceNode.onConnectionsChange = (
slotType,
slotIndex,
connected,
linkInfo,
ioSlot
) => {
if (!linkInfo) throw new Error('Expected link info')
callbackOrder.push(`source:${slotType}:${slotIndex}:${connected}`)
expect(slotType).toBe(NodeSlotType.OUTPUT)
expect(slotIndex).toBe(0)
expect(connected).toBe(true)
expect(linkInfo.origin_id).toBe(sourceNode.id)
expect(linkInfo.target_id).toBe(targetNode.id)
expect(ioSlot).toBe(sourceOutput)
}
targetNode.onConnectionsChange = (
slotType,
slotIndex,
connected,
linkInfo,
ioSlot
) => {
if (!linkInfo) throw new Error('Expected link info')
callbackOrder.push(`target:${slotType}:${slotIndex}:${connected}`)
expect(slotType).toBe(NodeSlotType.INPUT)
expect(slotIndex).toBe(0)
expect(connected).toBe(true)
expect(linkInfo.origin_id).toBe(sourceNode.id)
expect(linkInfo.target_id).toBe(targetNode.id)
expect(ioSlot).toBe(targetInput)
}
const link = sourceNode.connect(0, targetNode, 0)
expect(link).toBeDefined()
expect(callbackOrder).toEqual([
'trigger:node:slot-links:changed:true',
`source:${NodeSlotType.OUTPUT}:0:true`,
`target:${NodeSlotType.INPUT}:0:true`
])
})
})
describe('getInputPos and getOutputPos', () => {
test('should handle collapsed nodes correctly', () => {
const node = createMockLGraphNodeWithArrayBoundingRect('TestNode')

View File

@@ -10,11 +10,7 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { LayoutSource } from '@/renderer/core/layout/types'
import { adjustColor } from '@/utils/colorUtil'
import type { ColorAdjustOptions } from '@/utils/colorUtil'
import {
commonType,
isNodeBindable,
toClass
} from '@/lib/litegraph/src/utils/type'
import { isNodeBindable, toClass } from '@/lib/litegraph/src/utils/type'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import type { DragAndScale } from './DragAndScale'
@@ -27,6 +23,7 @@ import { LLink } from './LLink'
import type { Reroute, RerouteId } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw'
import { graphLifecycleEventDispatcher } from './infrastructure/GraphLifecycleEventDispatcher'
import { NullGraphError } from './infrastructure/NullGraphError'
import type { ReadOnlyRectangle } from './infrastructure/Rectangle'
import { Rectangle } from './infrastructure/Rectangle'
@@ -2866,8 +2863,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')
@@ -2913,84 +2908,23 @@ export class LGraphNode
inputNode.disconnectInput(inputIndex, true)
}
const maybeCommonType =
input.type && output.type && commonType(input.type, output.type)
const link = new LLink(
++graph.state.lastLinkId,
maybeCommonType || input.type || output.type,
this.id,
const link = graph.connectSlots(
this,
outputIndex,
inputNode.id,
inputNode,
inputIndex,
afterRerouteId
)
// 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
)
// 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
})
}
// 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)
}
}
}
graph._version++
// link has been created now, so its updated
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
outputIndex,
true,
link,
output
)
inputNode.onConnectionsChange?.(
NodeSlotType.INPUT,
inputIndex,
true,
link,
input
)
graphLifecycleEventDispatcher.dispatchConnectNodePair({
sourceNode: this,
sourceSlotIndex: outputIndex,
sourceSlot: output,
targetNode: inputNode,
targetSlotIndex: inputIndex,
targetSlot: input,
link
})
this.setDirtyCanvas(false, true)
graph.afterChange()
@@ -3110,35 +3044,29 @@ 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
})
}
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph,
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id,
hasWidget: !!input.widget
})
// remove the link from the links pool
link_info.disconnect(graph, 'input')
graph.disconnectLink(link_info, 'input')
graph._version++
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
NodeSlotType.INPUT,
link_info.target_slot,
false,
link_info,
input
)
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
false,
link_info,
output
)
graphLifecycleEventDispatcher.dispatchDisconnectNodePair({
sourceNode: this,
sourceSlotIndex: slot,
sourceSlot: output,
targetNode: target,
targetSlotIndex: link_info.target_slot,
targetSlot: input,
link: link_info
})
break
}
@@ -3153,7 +3081,24 @@ export class LGraphNode
) {
const targetSlot = graph.outputNode.slots[link_info.target_slot]
if (targetSlot) {
targetSlot.linkIds.length = 0
graph.disconnectSubgraphOutputLink(
targetSlot,
this,
slot,
link_info
)
// Compat: onConnectionsChange now fires for subgraph output
// disconnects (previously did not). Extensions should handle
// OUTPUT/disconnected callbacks idempotently.
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node: this,
slotType: NodeSlotType.OUTPUT,
slotIndex: slot,
connected: false,
link: link_info,
slot: output
})
continue
} else {
console.error('Missing subgraphOutput slot when disconnecting link')
}
@@ -3166,35 +3111,39 @@ 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
})
}
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph,
nodeId: target.id,
slotType: NodeSlotType.INPUT,
slotIndex: link_info.target_slot,
connected: false,
linkId: link_info.id,
hasWidget: !!input.widget
})
// link_info hasn't been modified so its ok
target.onConnectionsChange?.(
NodeSlotType.INPUT,
link_info.target_slot,
false,
link_info,
input
)
graph.disconnectLink(link_info, 'input')
graphLifecycleEventDispatcher.dispatchDisconnectNodePair({
sourceNode: this,
sourceSlotIndex: slot,
sourceSlot: output,
targetNode: target,
targetSlotIndex: link_info.target_slot,
targetSlot: input,
link: link_info
})
} else {
graph.disconnectLink(link_info, 'input')
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node: this,
slotType: NodeSlotType.OUTPUT,
slotIndex: slot,
connected: false,
link: link_info,
slot: output
})
}
// remove the link from the links pool
link_info.disconnect(graph, 'input')
this.onConnectionsChange?.(
NodeSlotType.OUTPUT,
slot,
false,
link_info,
output
)
}
output.links = null
}
@@ -3244,15 +3193,15 @@ export class LGraphNode
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
})
}
graphLifecycleEventDispatcher.dispatchSlotLinkChanged({
graph,
nodeId: this.id,
slotType: NodeSlotType.INPUT,
slotIndex: slot,
connected: false,
linkId: link_id,
hasWidget: !!input.widget
})
// remove other side
const link_info = graph._links.get(link_id)
@@ -3287,23 +3236,18 @@ export class LGraphNode
}
}
link_info.disconnect(graph, keepReroutes ? 'output' : undefined)
graph.disconnectLink(link_info, keepReroutes ? 'output' : undefined)
if (graph) graph._version++
this.onConnectionsChange?.(
NodeSlotType.INPUT,
slot,
false,
link_info,
input
)
target_node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
i,
false,
link_info,
output
)
graphLifecycleEventDispatcher.dispatchDisconnectNodePair({
sourceNode: target_node,
sourceSlotIndex: link_info.origin_slot,
sourceSlot: output,
targetNode: this,
targetSlotIndex: slot,
targetSlot: input,
link: link_info
})
}
}

View File

@@ -1,6 +1,7 @@
import { describe, expect } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { useLinkStore } from '@/stores/linkStore'
import { test } from './__fixtures__/testExtensions'
@@ -14,4 +15,61 @@ describe('LLink', () => {
const link = new LLink(1, 'float', 4, 2, 5, 3)
expect(link.serialize()).toMatchSnapshot('Basic')
})
test('origin and target lookups resolve via getLink accessor', () => {
const graph = new LGraph()
const originNode = new LGraphNode('origin')
originNode.addOutput('out', 'number')
const targetNode = new LGraphNode('target')
targetNode.addInput('in', 'number')
graph.add(originNode)
graph.add(targetNode)
originNode.connect(0, targetNode, 0)
const linkId = originNode.outputs[0].links?.[0]
if (linkId == null) throw new Error('Expected link ID')
const linkStore = useLinkStore()
const projectedLinks = new Map(graph.links)
linkStore.rehydrate(graph.linkStoreKey, {
links: projectedLinks,
floatingLinks: graph.floatingLinks,
reroutes: graph.reroutes
})
graph.links.clear()
expect(LLink.getOriginNode(graph, linkId)).toBe(originNode)
expect(LLink.getTargetNode(graph, linkId)).toBe(targetNode)
})
test('resolveMany resolves links via projected accessor', () => {
const graph = new LGraph()
const originNode = new LGraphNode('origin')
originNode.addOutput('out', 'number')
const targetNode = new LGraphNode('target')
targetNode.addInput('in', 'number')
graph.add(originNode)
graph.add(targetNode)
originNode.connect(0, targetNode, 0)
const linkId = originNode.outputs[0].links?.[0]
if (linkId == null) throw new Error('Expected link ID')
const linkStore = useLinkStore()
const projectedLinks = new Map(graph.links)
linkStore.rehydrate(graph.linkStoreKey, {
links: projectedLinks,
floatingLinks: graph.floatingLinks,
reroutes: graph.reroutes
})
graph.links.clear()
const [resolved] = LLink.resolveMany([linkId], graph)
expect(resolved?.link).toBe(projectedLinks.get(linkId))
expect(resolved?.outputNode).toBe(originNode)
expect(resolved?.inputNode).toBe(targetNode)
})
})

View File

@@ -244,7 +244,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
network: BasicReadonlyNetwork,
linkId: LinkId
): LGraphNode | undefined {
const id = network.links.get(linkId)?.origin_id
const id = network.getLink(linkId)?.origin_id
return network.getNodeById(id) ?? undefined
}
@@ -258,7 +258,7 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
network: BasicReadonlyNetwork,
linkId: LinkId
): LGraphNode | undefined {
const id = network.links.get(linkId)?.target_id
const id = network.getLink(linkId)?.target_id
return network.getNodeById(id) ?? undefined
}

View File

@@ -170,7 +170,7 @@ export class Reroute
const linkId = this.linkIds.values().next().value
return linkId === undefined
? undefined
: this.network.deref()?.links.get(linkId)
: this.network.deref()?.getLink(linkId)
}
get firstFloatingLink(): LLink | undefined {

View File

@@ -38,14 +38,16 @@ interface TestContext {
const test = baseTest.extend<TestContext>({
network: async ({}, use) => {
const graph = new LGraph()
const links = new Map<number, LLink>()
const floatingLinks = new Map<number, LLink>()
const reroutes = new Map<number, Reroute>()
await use({
links: new Map<number, LLink>(),
links,
reroutes,
floatingLinks,
getLink: graph.getLink.bind(graph),
getLink: ((id: number | null | undefined) =>
id == null ? undefined : links.get(id)) as LinkNetwork['getLink'],
getNodeById: (id: number) => graph.getNodeById(id),
addFloatingLink: (link: LLink) => {
floatingLinks.set(link.id, link)

View File

@@ -0,0 +1,130 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId, LLink } from '@/lib/litegraph/src/LLink'
import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type { SubgraphIO } from '@/lib/litegraph/src/types/serialisation'
type SlotConnection = INodeInputSlot | INodeOutputSlot | SubgraphIO
function dispatchSlotLinkChanged(params: {
graph: LGraph
nodeId: NodeId
slotType: NodeSlotType
slotIndex: number
connected: boolean
linkId: LinkId
hasWidget: boolean
}): void {
const { graph, nodeId, slotType, slotIndex, connected, linkId, hasWidget } =
params
if (!hasWidget) return
graph.trigger('node:slot-links:changed', {
nodeId,
slotType,
slotIndex,
connected,
linkId
})
}
function dispatchNodeConnectionChange(params: {
node: LGraphNode | undefined
slotType: NodeSlotType
slotIndex: number
connected: boolean
link: LLink | undefined
slot: SlotConnection
}): void {
const { node, slotType, slotIndex, connected, link, slot } = params
node?.onConnectionsChange?.(slotType, slotIndex, connected, link, slot)
}
// Dispatch ordering: connect fires OUTPUT→INPUT; disconnect fires INPUT→OUTPUT
// (LIFO-style teardown). disconnect accepts SubgraphIO as targetSlot because
// subgraph output nodes act as inputs inside the subgraph and are passed
// directly from the disconnect callsite.
function dispatchConnectNodePair(params: {
sourceNode: LGraphNode
sourceSlotIndex: number
sourceSlot: INodeOutputSlot
targetNode: LGraphNode
targetSlotIndex: number
targetSlot: INodeInputSlot
link: LLink
}): void {
const {
sourceNode,
sourceSlotIndex,
sourceSlot,
targetNode,
targetSlotIndex,
targetSlot,
link
} = params
dispatchNodeConnectionChange({
node: sourceNode,
slotType: NodeSlotType.OUTPUT,
slotIndex: sourceSlotIndex,
connected: true,
link,
slot: sourceSlot
})
dispatchNodeConnectionChange({
node: targetNode,
slotType: NodeSlotType.INPUT,
slotIndex: targetSlotIndex,
connected: true,
link,
slot: targetSlot
})
}
function dispatchDisconnectNodePair(params: {
sourceNode: LGraphNode
sourceSlotIndex: number
sourceSlot: INodeOutputSlot
targetNode: LGraphNode
targetSlotIndex: number
targetSlot: INodeInputSlot | SubgraphIO
link: LLink
}): void {
const {
sourceNode,
sourceSlotIndex,
sourceSlot,
targetNode,
targetSlotIndex,
targetSlot,
link
} = params
dispatchNodeConnectionChange({
node: targetNode,
slotType: NodeSlotType.INPUT,
slotIndex: targetSlotIndex,
connected: false,
link,
slot: targetSlot
})
dispatchNodeConnectionChange({
node: sourceNode,
slotType: NodeSlotType.OUTPUT,
slotIndex: sourceSlotIndex,
connected: false,
link,
slot: sourceSlot
})
}
export const graphLifecycleEventDispatcher = {
dispatchSlotLinkChanged,
dispatchNodeConnectionChange,
dispatchConnectNodePair,
dispatchDisconnectNodePair
}

View File

@@ -0,0 +1,128 @@
import { LLink } from '@/lib/litegraph/src/LLink'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type {
ISerialisedGraph,
ISerialisedNode,
SerialisableGraph,
SerialisableReroute
} from '@/lib/litegraph/src/types/serialisation'
type GraphStateLike = {
lastGroupId: number
lastLinkId: number
lastNodeId: number
lastRerouteId: number
}
type LinkNodeEndpoint = {
origin_id: NodeId
target_id: NodeId
}
type ConfiguredTopology = {
links: LLink[]
reroutes: SerialisableReroute[] | undefined
}
function mergeStateByMax(
state: GraphStateLike,
configuredState: SerialisableGraph['state'] | undefined
): void {
if (!configuredState) return
const { lastGroupId, lastLinkId, lastNodeId, lastRerouteId } = configuredState
if (lastGroupId != null)
state.lastGroupId = Math.max(state.lastGroupId, lastGroupId)
if (lastLinkId != null)
state.lastLinkId = Math.max(state.lastLinkId, lastLinkId)
if (lastNodeId != null)
state.lastNodeId = Math.max(state.lastNodeId, lastNodeId)
if (lastRerouteId != null)
state.lastRerouteId = Math.max(state.lastRerouteId, lastRerouteId)
}
function toConfiguredTopology(
data: ISerialisedGraph | SerialisableGraph,
state: GraphStateLike
): ConfiguredTopology {
if (data.version === 0.4) {
const links = Array.isArray(data.links)
? data.links.map((linkData) => LLink.createFromArray(linkData))
: []
const linkMap = new Map(links.map((link) => [link.id, link]))
if (Array.isArray(data.extra?.linkExtensions)) {
for (const linkExtension of data.extra.linkExtensions) {
const link = linkMap.get(linkExtension.id)
if (link) link.parentId = linkExtension.parentId
}
}
return {
links,
reroutes: data.extra?.reroutes
}
}
mergeStateByMax(state, data.state)
return {
links: Array.isArray(data.links)
? data.links.map((linkData) => LLink.create(linkData))
: [],
reroutes: data.reroutes
}
}
function patchLinkNodeIds(
links: Iterable<LinkNodeEndpoint> | undefined,
remappedIds: ReadonlyMap<NodeId, NodeId>
): void {
if (!links) return
for (const link of links) {
const newOriginId = remappedIds.get(link.origin_id)
if (newOriginId !== undefined) link.origin_id = newOriginId
const newTargetId = remappedIds.get(link.target_id)
if (newTargetId !== undefined) link.target_id = newTargetId
}
}
function remapNodeId(
nodeId: string,
remappedIds: ReadonlyMap<NodeId, NodeId>
): NodeId | undefined {
const directMatch = remappedIds.get(nodeId)
if (directMatch !== undefined) return directMatch
if (!/^-?\d+$/.test(nodeId)) return undefined
const numericId = Number(nodeId)
if (!Number.isSafeInteger(numericId)) return undefined
return remappedIds.get(numericId)
}
function remapProxyWidgets(
info: ISerialisedNode,
remappedIds: ReadonlyMap<NodeId, NodeId> | undefined
): void {
if (!remappedIds || remappedIds.size === 0) return
const proxyWidgets = info.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return
for (const entry of proxyWidgets) {
if (!Array.isArray(entry)) continue
const [nodeId] = entry
if (typeof nodeId !== 'string' || nodeId === '-1') continue
const remappedNodeId = remapNodeId(nodeId, remappedIds)
if (remappedNodeId !== undefined) entry[0] = String(remappedNodeId)
}
}
export const graphPersistenceAdapter = {
toConfiguredTopology,
patchLinkNodeIds,
remapProxyWidgets
}

View File

@@ -36,12 +36,14 @@ export interface SubgraphEventMap extends LGraphEventMap {
index: number
oldName: string
newName: string
canonicalName: string
}
'renaming-output': {
output: SubgraphOutput
index: number
oldName: string
newName: string
canonicalName: string
}
'widget-promoted': {

View File

@@ -107,7 +107,6 @@ export {
LGraph,
type GroupNodeConfigEntry,
type GroupNodeWorkflowData,
type LGraphTriggerAction,
type LGraphTriggerParam,
type GraphAddOptions
} from './LGraph'

View File

@@ -1,17 +1,241 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import {
LinkDirection,
NodeSlotType
} from '@/lib/litegraph/src/types/globalEnums'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraphData,
createTestSubgraph,
createTestSubgraphNode
} from './__fixtures__/subgraphHelpers'
describe('SubgraphIO - Slot Identity Normalization', () => {
subgraphTest(
'adds duplicate input/output names with stable canonical names while preserving display labels',
({ simpleSubgraph }) => {
const inputA = simpleSubgraph.addInput('duplicate', 'number')
const inputB = simpleSubgraph.addInput('duplicate', 'string')
const outputA = simpleSubgraph.addOutput('duplicate', 'number')
const outputB = simpleSubgraph.addOutput('duplicate', 'string')
expect(inputA.name).toBe('duplicate')
expect(inputA.displayName).toBe('duplicate')
expect(inputB.name).toBe(`duplicate__${inputB.id}`)
expect(inputB.label).toBe('duplicate')
expect(inputB.displayName).toBe('duplicate')
expect(outputA.name).toBe('duplicate')
expect(outputA.displayName).toBe('duplicate')
expect(outputB.name).toBe(`duplicate__${outputB.id}`)
expect(outputB.label).toBe('duplicate')
expect(outputB.displayName).toBe('duplicate')
}
)
subgraphTest(
'renaming to an existing slot name preserves display labels while assigning stable canonical identities',
({ simpleSubgraph }) => {
const inputA = simpleSubgraph.addInput('source', 'number')
const inputB = simpleSubgraph.addInput('target', 'number')
const outputA = simpleSubgraph.addOutput('source', 'number')
const outputB = simpleSubgraph.addOutput('target', 'number')
simpleSubgraph.renameInput(inputB, 'source')
simpleSubgraph.renameOutput(outputB, 'source')
expect(inputA.name).toBe('source')
expect(inputB.name).toBe(`source__${inputB.id}`)
expect(inputB.displayName).toBe('source')
expect(outputA.name).toBe('source')
expect(outputB.name).toBe(`source__${outputB.id}`)
expect(outputB.displayName).toBe('source')
}
)
it('normalizes legacy duplicate slot names into stable canonical identities on configure', () => {
const rootGraph = new LGraph()
const inputIdA = createUuidv4()
const inputIdB = createUuidv4()
const outputIdA = createUuidv4()
const outputIdB = createUuidv4()
const subgraph = rootGraph.createSubgraph(
createTestSubgraphData({
inputs: [
{ id: inputIdA, name: 'legacy', type: 'number' },
{ id: inputIdB, name: 'legacy', type: 'number' }
],
outputs: [
{ id: outputIdA, name: 'legacy', type: 'number' },
{ id: outputIdB, name: 'legacy', type: 'number' }
]
})
)
expect(subgraph.inputs.map((slot) => slot.name)).toEqual([
'legacy',
`legacy__${inputIdB}`
])
expect(subgraph.outputs.map((slot) => slot.name)).toEqual([
'legacy',
`legacy__${outputIdB}`
])
expect(subgraph.inputs.map((slot) => slot.displayName)).toEqual([
'legacy',
'legacy'
])
expect(subgraph.outputs.map((slot) => slot.displayName)).toEqual([
'legacy',
'legacy'
])
})
})
describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
subgraphTest(
'connect callback payload keeps current subgraph-input asymmetry',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Target')
internalNode.addInput('in', '*')
subgraph.add(internalNode)
const inputCallback = vi.fn()
internalNode.onConnectionsChange = inputCallback
const subgraphInput = subgraph.inputNode.slots[0]
const nodeInput = internalNode.inputs[0]
const link = subgraphInput.connect(nodeInput, internalNode)
expect(link).toBeDefined()
expect(link?.origin_id).toBe(SUBGRAPH_INPUT_ID)
expect(link?.target_id).toBe(internalNode.id)
expect(inputCallback).toHaveBeenCalledTimes(1)
expect(inputCallback).toHaveBeenLastCalledWith(
NodeSlotType.INPUT,
0,
true,
link,
nodeInput
)
}
)
subgraphTest(
'disconnect callback payload keeps current subgraph-input asymmetry',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Target')
internalNode.addInput('in', '*')
subgraph.add(internalNode)
const inputCallback = vi.fn()
internalNode.onConnectionsChange = inputCallback
const subgraphInput = subgraph.inputNode.slots[0]
const link = subgraphInput.connect(internalNode.inputs[0], internalNode)
if (!link) throw new Error('Expected link')
new ToInputFromIoNodeLink(
subgraph,
subgraph.inputNode,
subgraphInput,
undefined,
LinkDirection.CENTER,
link
).disconnect()
expect(inputCallback).toHaveBeenNthCalledWith(
1,
NodeSlotType.INPUT,
0,
true,
link,
internalNode.inputs[0]
)
expect(inputCallback).toHaveBeenNthCalledWith(
2,
NodeSlotType.INPUT,
0,
false,
link,
internalNode.inputs[0]
)
expect(internalNode.inputs[0].link).toBeNull()
expect(subgraphInput.linkIds).toEqual([])
expect(subgraph.links.get(link.id)).toBeUndefined()
}
)
subgraphTest(
'connect lifecycle keeps input-connected event before node callback',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Target')
internalNode.addInput('in', '*')
internalNode.addWidget('number', 'in-widget', 0, null)
internalNode.inputs[0].widget = { name: 'in-widget' }
subgraph.add(internalNode)
const subgraphInput = subgraph.inputNode.slots[0]
const callbackOrder: string[] = []
subgraphInput.events.addEventListener('input-connected', () => {
callbackOrder.push('event:input-connected')
})
internalNode.onConnectionsChange = () => {
callbackOrder.push('callback:node-connected')
}
subgraphInput.connect(internalNode.inputs[0], internalNode)
expect(callbackOrder).toEqual([
'event:input-connected',
'callback:node-connected'
])
}
)
subgraphTest(
'disconnect lifecycle keeps node callback before input-disconnected event',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Target')
internalNode.addInput('in', '*')
subgraph.add(internalNode)
const subgraphInput = subgraph.inputNode.slots[0]
subgraphInput.connect(internalNode.inputs[0], internalNode)
const callbackOrder: string[] = []
internalNode.onConnectionsChange = (...args) => {
if (args[2]) return
callbackOrder.push('callback:node-disconnected')
}
subgraphInput.events.addEventListener('input-disconnected', () => {
callbackOrder.push('event:input-disconnected')
})
subgraphInput.disconnect()
expect(callbackOrder).toEqual([
'callback:node-disconnected',
'event:input-disconnected'
])
}
)
subgraphTest(
'input accepts external connections from parent graph',
({ subgraphWithNode }) => {
@@ -101,6 +325,35 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
expect(internalNode.onConnectionsChange).toHaveBeenCalled()
})
subgraphTest(
'disconnects subgraph-input links even when link origin_slot points to a missing slot',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Target')
internalNode.addInput('in', '*')
subgraph.add(internalNode)
const subgraphInput = subgraph.inputNode.slots[0]
const link = subgraphInput.connect(internalNode.inputs[0], internalNode)
if (!link) throw new Error('Expected link')
// Simulate stale legacy/corrupt topology where the slot index no longer resolves.
link.origin_slot = 999
new ToInputFromIoNodeLink(
subgraph,
subgraph.inputNode,
subgraphInput,
undefined,
LinkDirection.CENTER,
link
).disconnect()
expect(internalNode.inputs[0].link).toBeNull()
expect(subgraph.links.get(link.id)).toBeUndefined()
}
)
subgraphTest(
'handles slot renaming with active connections',
({ subgraphWithNode }) => {
@@ -128,6 +381,75 @@ describe('SubgraphIO - Input Slot Dual-Nature Behavior', () => {
})
describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
subgraphTest(
'connect callback payload keeps current subgraph-output asymmetry',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Source')
internalNode.addOutput('out', '*')
subgraph.add(internalNode)
const outputCallback = vi.fn()
internalNode.onConnectionsChange = outputCallback
const subgraphOutput = subgraph.outputNode.slots[0]
const nodeOutput = internalNode.outputs[0]
const link = subgraphOutput.connect(nodeOutput, internalNode)
expect(link).toBeDefined()
expect(link?.origin_id).toBe(internalNode.id)
expect(link?.target_id).toBe(SUBGRAPH_OUTPUT_ID)
expect(outputCallback).toHaveBeenLastCalledWith(
NodeSlotType.OUTPUT,
0,
true,
link,
nodeOutput
)
}
)
subgraphTest(
'disconnect callback payload keeps current subgraph-output asymmetry',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Source')
internalNode.addOutput('out', '*')
subgraph.add(internalNode)
const outputCallback = vi.fn()
internalNode.onConnectionsChange = outputCallback
const subgraphOutput = subgraph.outputNode.slots[0]
const link = subgraphOutput.connect(internalNode.outputs[0], internalNode)
if (!link) throw new Error('Expected link')
subgraphOutput.disconnect()
expect(outputCallback).toHaveBeenNthCalledWith(
1,
NodeSlotType.OUTPUT,
0,
true,
link,
internalNode.outputs[0]
)
expect(outputCallback).toHaveBeenNthCalledWith(
2,
NodeSlotType.OUTPUT,
0,
false,
link,
subgraphOutput
)
expect(subgraph.links.get(link.id)).toBeUndefined()
expect(subgraphOutput.linkIds).toEqual([])
expect(internalNode.outputs[0].links).toEqual([])
}
)
subgraphTest(
'output provides connections to parent graph',
({ subgraphWithNode }) => {
@@ -218,6 +540,54 @@ describe('SubgraphIO - Output Slot Dual-Nature Behavior', () => {
expect(subgraph.outputs[0].displayName).toBe('new_name')
}
)
subgraphTest(
'cleans stale subgraph-output linkIds while disconnecting active output links',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Source')
internalNode.addOutput('out', '*')
subgraph.add(internalNode)
const subgraphOutput = subgraph.outputNode.slots[0]
const link = subgraphOutput.connect(internalNode.outputs[0], internalNode)
if (!link) throw new Error('Expected link')
// Simulate stale/corrupt bookkeeping where a dead link id remains.
const staleLinkId = 999_999
subgraphOutput.linkIds.push(staleLinkId)
subgraphOutput.disconnect()
expect(subgraphOutput.linkIds).toEqual([])
expect(subgraph.links.get(link.id)).toBeUndefined()
expect(internalNode.outputs[0].links).toEqual([])
}
)
subgraphTest(
'disconnect is idempotent and keeps output endpoints detached',
({ subgraphWithNode }) => {
const { subgraph } = subgraphWithNode
const internalNode = new LGraphNode('Internal Source')
internalNode.addOutput('out', '*')
subgraph.add(internalNode)
const subgraphOutput = subgraph.outputNode.slots[0]
const link = subgraphOutput.connect(internalNode.outputs[0], internalNode)
if (!link) throw new Error('Expected link')
subgraphOutput.disconnect()
subgraphOutput.disconnect()
expect(subgraph.getLink(link.id)).toBeUndefined()
expect(subgraphOutput.getLinks()).toEqual([])
expect(subgraphOutput.linkIds).toEqual([])
expect(internalNode.outputs[0].links).toEqual([])
}
)
})
describe('SubgraphIO - Boundary Connection Management', () => {

View File

@@ -1,7 +1,8 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
import type { SubgraphInputEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphInputEventMap'
import type {
INodeInputSlot,
@@ -97,44 +98,21 @@ export class SubgraphInput extends SubgraphSlot {
})
}
const link = new LLink(
++subgraph.state.lastLinkId,
slot.type,
this.parent.id,
this.parent.slots.indexOf(this),
node.id,
const link = subgraph.connectSubgraphInputSlot(
this,
node,
inputIndex,
afterRerouteId
)
// Add to graph links list
subgraph._links.set(link.id, 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)
}
}
}
subgraph._version++
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node,
slotType: NodeSlotType.INPUT,
slotIndex: inputIndex,
connected: true,
link,
slot
})
subgraph.afterChange()

View File

@@ -1,9 +1,10 @@
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
import type {
DefaultConnectionColors,
INodeInputSlot,
@@ -91,31 +92,6 @@ export class SubgraphInputNode
return inputNode.canConnectTo(this, input, fromSlot)
}
connectSlots(
fromSlot: SubgraphInput,
inputNode: LGraphNode,
input: INodeInputSlot,
afterRerouteId: RerouteId | undefined
): LLink {
const { subgraph } = this
const outputIndex = this.slots.indexOf(fromSlot)
const inputIndex = inputNode.inputs.indexOf(input)
if (outputIndex === -1 || inputIndex === -1)
throw new Error('Invalid slot indices.')
return new LLink(
++subgraph.state.lastLinkId,
input.type || fromSlot.type,
this.id,
outputIndex,
inputNode.id,
inputIndex,
afterRerouteId
)
}
// #region Legacy LGraphNode compatibility
connectByType(
@@ -170,52 +146,57 @@ export class SubgraphInputNode
): void {
const { subgraph } = this
// Break floating links
if (input._floatingLinks?.size) {
for (const link of input._floatingLinks) {
subgraph.removeFloatingLink(link)
}
const slotIndex = node.inputs.findIndex((inp) => inp === input)
if (slotIndex === -1) {
console.warn('disconnectNodeInput: target input slot not found', this)
return
}
input.link = null
subgraph.setDirtyCanvas(false, true)
if (!link) return
const subgraphInputIndex = link.origin_slot
link.disconnect(subgraph, 'output')
subgraph._version++
const subgraphInput = this.slots.at(subgraphInputIndex)
const subgraphInput = link ? this.slots.at(link.origin_slot) : undefined
if (!subgraphInput) {
console.warn(
'disconnectNodeInput: subgraphInput not found',
this,
subgraphInputIndex
link?.origin_slot
)
if (input._floatingLinks?.size) {
for (const floatingLink of input._floatingLinks) {
subgraph.removeFloatingLink(floatingLink)
}
}
input.link = null
if (link) {
subgraph.disconnectLink(link, 'output')
subgraph._version++
}
subgraph.setDirtyCanvas(false, true)
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node,
slotType: NodeSlotType.INPUT,
slotIndex,
connected: false,
link,
slot: input
})
return
}
// search in the inputs list for this link
const index = subgraphInput.linkIds.indexOf(link.id)
if (index !== -1) {
subgraphInput.linkIds.splice(index, 1)
} else {
console.warn(
'disconnectNodeInput: link ID not found in subgraphInput linkIds',
link.id
)
}
const slotIndex = node.inputs.findIndex((inp) => inp === input)
if (slotIndex !== -1) {
node.onConnectionsChange?.(
NodeSlotType.INPUT,
slotIndex,
false,
link,
subgraphInput
)
}
subgraph.disconnectSubgraphInputLink(subgraphInput, node, slotIndex, link)
subgraph.setDirtyCanvas(false, true)
// Compat: onConnectionsChange 5th arg is now the INodeInputSlot (previously
// passed the SubgraphInput). Extensions relying on the old type should
// adapt to accept INodeInputSlot.
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node,
slotType: NodeSlotType.INPUT,
slotIndex,
connected: false,
link,
slot: input
})
}
override drawProtected(

View File

@@ -174,7 +174,8 @@ describe.skip('SubgraphNode Synchronization', () => {
input: subgraph.inputs[0],
index: 0,
oldName: 'oldName',
newName: 'newName'
newName: 'newName',
canonicalName: 'newName'
})
expect(subgraphNode.inputs[0].label).toBe('newName')
@@ -185,7 +186,8 @@ describe.skip('SubgraphNode Synchronization', () => {
output: subgraph.outputs[0],
index: 0,
oldName: 'oldOutput',
newName: 'newOutput'
newName: 'newOutput',
canonicalName: 'newOutput'
})
expect(subgraphNode.outputs[0].label).toBe('newOutput')

View File

@@ -380,7 +380,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const existingInput = this.inputs.find((i) => i.name === name)
if (existingInput) {
const linkId = subgraphInput.linkIds[0]
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const link = subgraph.links.get(linkId)
if (!link) return
const { inputNode, input } = link.resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name === name)
if (widget && inputNode)
this._setWidget(
@@ -430,14 +433,18 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
{ signal }
)
// Compat: `.name` now changes on rename (previously only `.label` changed).
// Extensions that key on `input.name` should be aware of this.
subgraphEvents.addEventListener(
'renaming-input',
(e) => {
const { index, newName } = e.detail
const { index, newName, canonicalName } = e.detail
const input = this.inputs.at(index)
if (!input) throw new Error('Subgraph input not found')
input.name = canonicalName
input.label = newName
if (input.widget) input.widget.name = input.name
if (input._widget) {
input._widget.label = newName
}
@@ -448,10 +455,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphEvents.addEventListener(
'renaming-output',
(e) => {
const { index, newName } = e.detail
const { index, newName, canonicalName } = e.detail
const output = this.outputs.at(index)
if (!output) throw new Error('Subgraph output not found')
output.name = canonicalName
output.label = newName
},
{ signal }

View File

@@ -1,8 +1,7 @@
import { pull } from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { graphLifecycleEventDispatcher } from '@/lib/litegraph/src/infrastructure/GraphLifecycleEventDispatcher'
import type {
INodeInputSlot,
INodeOutputSlot,
@@ -11,6 +10,7 @@ import type {
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
import type { SubgraphInput } from './SubgraphInput'
import type { SubgraphOutputNode } from './SubgraphOutputNode'
@@ -52,66 +52,53 @@ export class SubgraphOutput extends SubgraphSlot {
)
return
// Link should not be present, but just in case, disconnect it
const existingLink = this.getLinks().at(0)
if (existingLink != null) {
subgraph.beforeChange()
subgraph.beforeChange()
try {
// Link should not be present, but just in case, disconnect it
const existingLink = this.getLinks().at(0)
if (existingLink != null) {
const { outputNode } = existingLink.resolve(subgraph)
if (!outputNode)
throw new Error('Expected output node for existing link')
existingLink.disconnect(subgraph, 'input')
const resolved = existingLink.resolve(subgraph)
const links = resolved.output?.links
if (links) pull(links, existingLink.id)
}
subgraph.disconnectSubgraphOutputLink(
this,
outputNode,
existingLink.origin_slot,
existingLink
)
const link = new LLink(
++subgraph.state.lastLinkId,
slot.type,
node.id,
outputIndex,
this.parent.id,
this.parent.slots.indexOf(this),
afterRerouteId
)
// Add to graph links list
subgraph._links.set(link.id, 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)
}
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node: outputNode,
slotType: NodeSlotType.OUTPUT,
slotIndex: existingLink.origin_slot,
connected: false,
link: existingLink,
slot: this
})
}
const link = subgraph.connectSubgraphOutputSlot(
node,
outputIndex,
this,
afterRerouteId
)
if (!link) return
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node,
slotType: NodeSlotType.OUTPUT,
slotIndex: outputIndex,
connected: true,
link,
slot
})
return link
} finally {
subgraph.afterChange()
}
subgraph._version++
node.onConnectionsChange?.(
NodeSlotType.OUTPUT,
outputIndex,
true,
link,
slot
)
subgraph.afterChange()
return link
}
get labelPos(): Point {
@@ -153,24 +140,42 @@ export class SubgraphOutput extends SubgraphSlot {
return false
}
private static _disconnectDeprecationWarned = false
override disconnect() {
const { subgraph } = this.parent
//should never have more than one connection
for (const linkId of this.linkIds) {
const link = subgraph.links[linkId]
if (!link) continue
subgraph.removeLink(linkId)
const { output, outputNode } = link.resolve(subgraph)
if (output)
output.links = output.links?.filter((id) => id !== linkId) ?? null
outputNode?.onConnectionsChange?.(
NodeSlotType.OUTPUT,
link.origin_slot,
false,
link,
this
if (!SubgraphOutput._disconnectDeprecationWarned) {
SubgraphOutput._disconnectDeprecationWarned = true
warnDeprecated(
'[DEPRECATED] SubgraphOutput.disconnect now dispatches onConnectionsChange for output-node disconnect parity. Remedy: update extension handlers to treat OUTPUT/disconnected callbacks as the canonical disconnect signal and no-op safely if already detached.'
)
}
//should never have more than one connection
for (const linkId of [...this.linkIds]) {
const link = subgraph.links.get(linkId)
if (!link) continue
const { outputNode } = link.resolve(subgraph)
if (!outputNode) continue
subgraph.disconnectSubgraphOutputLink(
this,
outputNode,
link.origin_slot,
link
)
graphLifecycleEventDispatcher.dispatchNodeConnectionChange({
node: outputNode,
slotType: NodeSlotType.OUTPUT,
slotIndex: link.origin_slot,
connected: false,
link,
slot: this
})
}
this.linkIds.length = 0
}
}

View File

@@ -1,8 +1,9 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { LinkId, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import {
@@ -51,6 +52,12 @@ export function splitPositionables(
for (const item of items) {
switch (true) {
case item instanceof SubgraphInputNode:
subgraphInputNodes.add(item)
break
case item instanceof SubgraphOutputNode:
subgraphOutputNodes.add(item)
break
case item instanceof LGraphNode:
nodes.add(item)
break
@@ -60,12 +67,6 @@ export function splitPositionables(
case item instanceof Reroute:
reroutes.add(item)
break
case item instanceof SubgraphInputNode:
subgraphInputNodes.add(item)
break
case item instanceof SubgraphOutputNode:
subgraphOutputNodes.add(item)
break
default:
unknown.add(item)
break
@@ -90,6 +91,64 @@ interface BoundaryLinks {
boundaryOutputLinks: LLink[]
}
interface SubgraphBoundaryNodeView {
id: NodeId
inputs: Array<{ link?: LinkId | null }>
outputs: Array<{ links?: LinkId[] | null }>
}
interface SubgraphBoundaryOutputEndpoint {
targetId: NodeId
targetSlot: number
externalParentId: RerouteId | undefined
}
interface SubgraphBoundaryInputEndpoint {
originId: NodeId
originSlot: number
externalParentId: RerouteId | undefined
}
export const subgraphBoundaryAdapter = {
remapInputBoundaryForUnpack(
link: LLink,
subgraphNode: SubgraphBoundaryNodeView,
links: Map<LinkId, LLink>
): SubgraphBoundaryInputEndpoint | undefined {
const outerLinkId = subgraphNode.inputs[link.origin_slot]?.link
if (outerLinkId == null) return
const outerLink = links.get(outerLinkId)
if (!outerLink) return
return {
originId: outerLink.origin_id,
originSlot: outerLink.origin_slot,
externalParentId: outerLink.parentId
}
},
resolveOutputBoundaryForUnpack(
link: LLink,
subgraphNode: SubgraphBoundaryNodeView,
links: Map<LinkId, LLink>
): SubgraphBoundaryOutputEndpoint[] {
const results: SubgraphBoundaryOutputEndpoint[] = []
for (const linkId of subgraphNode.outputs[link.target_slot]?.links ?? []) {
const outerLink = links.get(linkId)
if (!outerLink) continue
results.push({
targetId: outerLink.target_id,
targetSlot: outerLink.target_slot,
externalParentId: outerLink.parentId
})
}
return results
}
}
export function getBoundaryLinks(
graph: LGraph,
items: Set<Positionable>
@@ -263,7 +322,7 @@ export function groupResolvedByOutput(
function mapReroutes(
link: SerialisableLLink,
reroutes: Map<RerouteId, Reroute>
) {
): RerouteId | undefined {
let child: SerialisableLLink | Reroute = link
let nextReroute =
child.parentId === undefined ? undefined : reroutes.get(child.parentId)

View File

@@ -0,0 +1,41 @@
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
const DUPLICATE_IDENTITY_SEPARATOR = '__'
export function resolveCanonicalSlotName<
TSlot extends { id: UUID; name: string }
>(slots: readonly TSlot[], requestedName: string, slotId: UUID): string {
if (!slots.some((slot) => slot.id !== slotId && slot.name === requestedName))
return requestedName
return `${requestedName}${DUPLICATE_IDENTITY_SEPARATOR}${slotId}`
}
export function normalizeLegacySlotIdentity<
TSlot extends { id: UUID; name: string; label?: string }
>(slots: TSlot[]): void {
const seenCounts = new Map<string, number>()
for (const slot of slots) {
const count = seenCounts.get(slot.name) ?? 0
seenCounts.set(slot.name, count + 1)
if (count === 0) continue
warnDeprecated(
'[DEPRECATED] Legacy subgraph workflows with duplicate slot names are automatically canonicalized by appending a stable slot ID. Remedy: resave the workflow in the current frontend to persist canonical slot names and avoid compatibility fallback.'
)
const oldName = slot.name
slot.label ??= slot.name
slot.name = `${slot.name}${DUPLICATE_IDENTITY_SEPARATOR}${slot.id}`
console.warn(
'Subgraph slot identity deduplicated during legacy normalization',
{
slotId: slot.id,
oldName,
canonicalName: slot.name
}
)
}
}

View File

@@ -204,10 +204,8 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const { slotMetadata } = widget
// Get metadata from store (registered during BaseWidget.setNodeId)
const bareWidgetId = stripGraphPrefix(
widget.storeNodeId ?? widget.nodeId ?? nodeId
)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const storeWidgetName = widget.name
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined

View File

@@ -22,8 +22,7 @@ vi.mock('@/utils/litegraphUtil', () => ({
isImageNode: vi.fn(),
isVideoNode: vi.fn(),
isAudioNode: vi.fn(),
executeWidgetsCallback: vi.fn(),
fixLinkInputSlots: vi.fn()
executeWidgetsCallback: vi.fn()
}))
vi.mock('@/composables/usePaste', () => ({

View File

@@ -5,8 +5,7 @@ import { reactive, unref } from 'vue'
import { shallowRef } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { addAfterConfigureHandler } from '@/utils/graphConfigureUtil'
import { st, t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
@@ -91,7 +90,6 @@ import {
import {
executeWidgetsCallback,
createNode,
fixLinkInputSlots,
isImageNode,
isVideoNode
} from '@/utils/litegraphUtil'
@@ -795,37 +793,6 @@ export class ComfyApp {
}
}
private addAfterConfigureHandler(graph: LGraph) {
const { onConfigure } = graph
graph.onConfigure = function (...args) {
// Set pending sync flag to suppress link rendering until slots are synced
if (LiteGraph.vueNodesMode) {
layoutStore.setPendingSlotSync(true)
}
try {
fixLinkInputSlots(this)
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
triggerCallbackOnAllNodes(this, 'onGraphConfigured')
const r = onConfigure?.apply(this, args)
// Fire after onConfigure, used by primitives to generate widget using input nodes config
triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured')
return r
} finally {
// Flush pending slot layout syncs to fix link alignment after undo/redo
// Using finally ensures links aren't permanently suppressed if an error occurs
if (LiteGraph.vueNodesMode) {
flushScheduledSlotLayoutSync()
app.canvas?.setDirty(true, true)
}
}
}
}
/**
* Set up the app on the page
*/
@@ -864,7 +831,7 @@ export class ComfyApp {
}
})
this.addAfterConfigureHandler(graph)
addAfterConfigureHandler(graph, () => this.canvas)
this.rootGraphInternal = graph
this.canvas = new LGraphCanvas(canvasEl, graph)

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { ChangeTracker } from './changeTracker'
function createTopologyGraph() {
const graph = new LGraph()
const source = new LGraphNode('source')
source.addOutput('out', 'number')
const floatingTarget = new LGraphNode('floating-target')
floatingTarget.addInput('in', 'number')
const linkedTarget = new LGraphNode('linked-target')
linkedTarget.addInput('in', 'number')
graph.add(source)
graph.add(floatingTarget)
graph.add(linkedTarget)
source.connect(0, floatingTarget, 0)
source.connect(0, linkedTarget, 0)
const link = graph.getLink(floatingTarget.inputs[0].link)
if (!link) throw new Error('Expected link')
graph.createReroute([100, 100], link)
floatingTarget.disconnectInput(0, true)
return graph
}
describe('ChangeTracker.graphEqual', () => {
it('returns false when links differ', () => {
const graph = createTopologyGraph()
const stateA = graph.asSerialisable() as unknown as ComfyWorkflowJSON
const stateB = structuredClone(stateA)
stateB.links = []
expect(ChangeTracker.graphEqual(stateA, stateB)).toBe(false)
})
it('returns false when floatingLinks differ', () => {
const graph = createTopologyGraph()
const stateA = graph.asSerialisable() as unknown as ComfyWorkflowJSON
const stateB = structuredClone(stateA)
stateB.floatingLinks = []
expect(ChangeTracker.graphEqual(stateA, stateB)).toBe(false)
})
it('returns false when reroutes differ', () => {
const graph = createTopologyGraph()
const stateA = graph.asSerialisable() as unknown as ComfyWorkflowJSON
const stateB = structuredClone(stateA)
stateB.reroutes = []
expect(ChangeTracker.graphEqual(stateA, stateB)).toBe(false)
})
})

81
src/stores/linkStore.ts Normal file
View File

@@ -0,0 +1,81 @@
import { defineStore } from 'pinia'
import type { LinkId, LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute, RerouteId } from '@/lib/litegraph/src/Reroute'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
/**
* ReadonlyMap fields are live projections of mutable graph-owned Maps,
* not immutable snapshots. Do not cache or rely on referential stability.
*/
interface LinkStoreTopology {
links: ReadonlyMap<LinkId, LLink>
floatingLinks: ReadonlyMap<LinkId, LLink>
reroutes: ReadonlyMap<RerouteId, Reroute>
}
const EMPTY_TOPOLOGY: Readonly<LinkStoreTopology> = Object.freeze({
links: new Map(),
floatingLinks: new Map(),
reroutes: new Map()
})
/**
* Graph-scoped topology store (Pinia).
*
* This store owns no mutation logic and is rehydrated from graph lifecycle
* boundaries (`clear` and `configure`). The `ReadonlyMap` fields are live
* projections of mutable graph state, not immutable snapshots.
*
* Each graph/subgraph registers its own topology keyed by graph UUID.
*/
export const useLinkStore = defineStore('link', () => {
// Intentionally non-reactive: used as an imperative graph lookup boundary,
// not as UI-driven reactive state.
const topologies = new Map<UUID, LinkStoreTopology>()
function rehydrate(graphId: UUID, topology: LinkStoreTopology) {
topologies.set(graphId, topology)
}
function getTopology(graphId: UUID): LinkStoreTopology {
return topologies.get(graphId) ?? EMPTY_TOPOLOGY
}
function getLink(
graphId: UUID,
id: LinkId | null | undefined
): LLink | undefined {
if (id == null) return undefined
return topologies.get(graphId)?.links.get(id)
}
function getFloatingLink(
graphId: UUID,
id: LinkId | null | undefined
): LLink | undefined {
if (id == null) return undefined
return topologies.get(graphId)?.floatingLinks.get(id)
}
function getReroute(
graphId: UUID,
id: RerouteId | null | undefined
): Reroute | undefined {
if (id == null) return undefined
return topologies.get(graphId)?.reroutes.get(id)
}
function clearGraph(graphId: UUID) {
topologies.delete(graphId)
}
return {
rehydrate,
getTopology,
getLink,
getFloatingLink,
getReroute,
clearGraph
}
})

View File

@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { fixLinkInputSlots } from '@/utils/litegraphUtil'
import { triggerCallbackOnAllNodes } from '@/utils/graphTraversalUtil'
import { addAfterConfigureHandler } from './graphConfigureUtil'
vi.mock('@/utils/litegraphUtil', () => ({
fixLinkInputSlots: vi.fn()
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
triggerCallbackOnAllNodes: vi.fn()
}))
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: { setPendingSlotSync: vi.fn() }
}))
vi.mock(
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
() => ({
flushScheduledSlotLayoutSync: vi.fn()
})
)
function createConfigureGraph(): LGraph {
return {
nodes: [],
onConfigure: vi.fn()
} satisfies Partial<LGraph> as unknown as LGraph
}
describe('addAfterConfigureHandler', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('runs legacy slot repair on configure', () => {
const graph = createConfigureGraph()
addAfterConfigureHandler(graph, () => undefined)
graph.onConfigure!.call(
graph,
{} as Parameters<NonNullable<LGraph['onConfigure']>>[0]
)
expect(fixLinkInputSlots).toHaveBeenCalledWith(graph)
})
it('runs onAfterGraphConfigured even if onConfigure throws', () => {
const graph = createConfigureGraph()
graph.onConfigure = vi.fn(() => {
throw new Error('onConfigure failed')
})
addAfterConfigureHandler(graph, () => undefined)
expect(() =>
graph.onConfigure!.call(
graph,
{} as Parameters<NonNullable<LGraph['onConfigure']>>[0]
)
).toThrow('onConfigure failed')
expect(triggerCallbackOnAllNodes).toHaveBeenCalledWith(
graph,
'onAfterGraphConfigured'
)
})
})

View File

@@ -0,0 +1,37 @@
import type { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { flushScheduledSlotLayoutSync } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { triggerCallbackOnAllNodes } from '@/utils/graphTraversalUtil'
import { fixLinkInputSlots } from '@/utils/litegraphUtil'
/**
* Wraps graph.onConfigure to add legacy slot repair,
* node configure callbacks, and layout sync flushing.
*/
export function addAfterConfigureHandler(
graph: LGraph,
getCanvas: () => LGraphCanvas | undefined
) {
const { onConfigure } = graph
graph.onConfigure = function (...args) {
if (LiteGraph.vueNodesMode) {
layoutStore.setPendingSlotSync(true)
}
try {
fixLinkInputSlots(this)
triggerCallbackOnAllNodes(this, 'onGraphConfigured')
return onConfigure?.apply(this, args)
} finally {
triggerCallbackOnAllNodes(this, 'onAfterGraphConfigured')
if (LiteGraph.vueNodesMode) {
flushScheduledSlotLayoutSync()
getCanvas()?.setDirty(true, true)
}
}
}
}

View File

@@ -13,6 +13,7 @@ import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import {
compressWidgetInputSlots,
createNode,
fixLinkInputSlots,
isAnimatedOutput,
isVideoOutput,
migrateWidgetsValues,
@@ -26,6 +27,10 @@ vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => ({
}
}))
vi.mock('@/lib/litegraph/src/utils/feedback', () => ({
warnDeprecated: vi.fn()
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: null }
}))
@@ -391,6 +396,82 @@ describe('compressWidgetInputSlots', () => {
})
})
function createGraphWithLinks(options: {
targetSlot: number
inputLink: number | null
nestedTargetSlot?: number
nestedInputLink?: number | null
}) {
const nestedGraph = {
nodes: [
{
inputs: [{ link: options.nestedInputLink ?? null }],
isSubgraphNode: vi.fn(() => false)
}
],
links: new Map(
options.nestedInputLink
? [
[
options.nestedInputLink,
{ target_slot: options.nestedTargetSlot ?? 0 }
]
]
: []
)
}
const graph = {
nodes: [
{
inputs: [{ link: options.inputLink }],
isSubgraphNode: vi.fn(() => true),
subgraph: nestedGraph
}
],
links: new Map(
options.inputLink
? [[options.inputLink, { target_slot: options.targetSlot }]]
: []
)
}
return {
graph: graph as unknown as LGraph,
nestedGraph
}
}
describe('fixLinkInputSlots', () => {
it('repairs stale target slot indices recursively', () => {
const { graph, nestedGraph } = createGraphWithLinks({
targetSlot: 4,
inputLink: 11,
nestedTargetSlot: 3,
nestedInputLink: 22
})
const result = fixLinkInputSlots(graph)
expect(result).toBe(true)
expect(graph.links.get(11)?.target_slot).toBe(0)
expect(nestedGraph.links.get(22)?.target_slot).toBe(0)
})
it('returns false when no repair is needed', () => {
const { graph } = createGraphWithLinks({
targetSlot: 0,
inputLink: 11,
nestedTargetSlot: 0,
nestedInputLink: 22
})
const result = fixLinkInputSlots(graph)
expect(result).toBe(false)
})
})
describe('resolveNode', () => {
function mockGraph(
nodeList: Partial<LGraphNode>[],

View File

@@ -10,6 +10,7 @@ import {
Reroute,
isColorable
} from '@/lib/litegraph/src/litegraph'
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
import type {
ExportedSubgraph,
ISerialisableNodeInput,
@@ -218,11 +219,10 @@ export function migrateWidgetsValues<TWidgetValue>(
*
* @param graph - The graph to fix links for.
*/
export function fixLinkInputSlots(graph: LGraph) {
// Note: We can't use forEachNode here because we need access to the graph's
// links map at each level. Links are stored in their respective graph/subgraph.
export function fixLinkInputSlots(graph: LGraph, isRoot = true): boolean {
let hasMismatch = false
for (const node of graph.nodes) {
// Fix links for the current node
for (const [inputIndex, input] of node.inputs.entries()) {
const linkId = input.link
if (!linkId) continue
@@ -230,14 +230,24 @@ export function fixLinkInputSlots(graph: LGraph) {
const link = graph.links.get(linkId)
if (!link) continue
link.target_slot = inputIndex
if (link.target_slot !== inputIndex) {
link.target_slot = inputIndex
hasMismatch = true
}
}
// Recursively fix links in subgraphs
if (node.isSubgraphNode?.() && node.subgraph) {
fixLinkInputSlots(node.subgraph)
if (fixLinkInputSlots(node.subgraph, false)) hasMismatch = true
}
}
if (isRoot && hasMismatch) {
warnDeprecated(
'[DEPRECATED] Legacy slot-index repair (fixLinkInputSlots) now narrows to connected inputs only. Remedy: resave workflows in the current frontend to persist canonical link target slots and remove reliance on migration repair.'
)
}
return hasMismatch
}
/**

View File

@@ -1,6 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { vi } from 'vitest'
import 'vue'
setActivePinia(createTestingPinia({ stubActions: false }))
// Mock @sparkjsdev/spark which uses WASM that doesn't work in Node.js
vi.mock('@sparkjsdev/spark', () => ({
SplatMesh: class SplatMesh {