Compare commits

...

13 Commits

Author SHA1 Message Date
Alexander Brown
74a48ab2aa fix: stabilize subgraph promoted widget identity and rendering (#9896)
## Summary

Fix subgraph promoted widget identity/rendering so on-node widgets stay
correct through configure/hydration churn, duplicate names, and
linked+independent coexistence.

## Changes

- **Subgraph promotion reconciliation**: stabilize linked-entry identity
by subgraph slot id, preserve deterministic linked representative
selection, and prune stale alias/fallback entries without dropping
legitimate independent promotions.
- **Promoted view resolution**: bind slot mapping by promoted view
object identity (`getSlotFromWidget` / `getWidgetFromSlot`) to avoid
same-name collisions.
- **On-node widget rendering**: harden `NodeWidgets` identity and dedup
to avoid visual aliasing, prefer visible duplicates over hidden stale
entries, include type/source execution identity, and avoid collapsing
transient unresolved entries.
- **Mapping correctness**: update `useGraphNodeManager` promoted source
mapping to resolve by input target only when the promoted view is
actually bound to that input.
- **Subgraph input uniqueness**: ensure empty-slot promotion creates
unique input names (`seed`, `seed_1`, etc.) for same-name multi-source
promotions.
- **Safety fix**: guard against undefined canvas in slot-link
interaction.
- **Tests/fixtures**: add focused regressions for fixture path
`subgraph_complex_promotion_1`, linked+independent same-name cases,
duplicate-name identity mapping, dedup behavior, and input-name
uniqueness.

## Review Focus

Validate behavior around transient configure/hydration states (`-1` id
to concrete id), duplicate-name promotions, linked representative
recovery, and that dedup never hides legitimate widgets while still
removing true duplicates.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9896-fix-stabilize-subgraph-promoted-widget-identity-and-rendering-3226d73d365081c8a1e8d0a5a22e826d)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 11:30:31 -07:00
Christian Byrne
0875e2f50f fix: clear stale widget slotMetadata on link disconnect (#9885)
## Summary
Fixes text field becoming non-editable when a previously linked input is
removed from a custom node.

## Problem
When a widget's input was promoted to a slot, connected via a link, and
then the input was removed (e.g., by updating the custom node
definition), the widget retained stale `slotMetadata` with `linked:
true`. This prevented the widget from being editable.

## Solution
In `refreshNodeSlots`, removed the `if (slotInfo)` guard so
`widget.slotMetadata` is always assigned — either to valid metadata or
`undefined`. This ensures stale linked state is cleared when inputs no
longer match widgets.

## Acceptance Criteria
1. Text field remains editable after promote→connect→disconnect cycle
2. Text field returns to editable state when noodle disconnected
3. No mode switching needed to restore editability

## Testing
- Added regression test: "clears stale slotMetadata when input no longer
matches widget"
- All existing tests pass (18/18 in affected file)

---
**Note: This PR currently contains only the RED (failing test) commit
for TDD verification. The GREEN (fix) commit will be pushed after CI
confirms the test failure.**

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9885-fix-clear-stale-widget-slotMetadata-on-link-disconnect-3226d73d365081269319c027b42d9f6b)
by [Unito](https://www.unito.io)
2026-03-14 08:13:34 -07:00
Johnpaul Chiwetelu
63442d2fb0 fix: restore native copy/paste events for image paste support (#9914)
## Summary

- Remove Ctrl+C and Ctrl+V keybindings from the keybinding service
defaults so native browser copy/paste events fire
- This restores image paste into LoadImage nodes, which broke after
#9459

## Problem

PR #9459 moved Ctrl+C/V into the keybinding service, which calls
`event.preventDefault()` on keydown. This prevents the browser `paste`
event from firing, so `usePaste` (which detects images in the clipboard)
never runs. The `PasteFromClipboard` command only reads from
localStorage, completely bypassing image detection.

**Repro:** Copy a node → copy an image externally → try to paste the
image into a LoadImage node → gets old node data from localStorage
instead.

## Fix

Remove Ctrl+C and Ctrl+V from `CORE_KEYBINDINGS` in `defaults.ts`. The
native browser events now fire as before, and `useCopy`/`usePaste`
handle them correctly. Ctrl+Shift+V, Ctrl+A, Delete, and Backspace
keybindings remain in the keybinding service.

Fixes #9459 (regression)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9914-fix-restore-native-copy-paste-events-for-image-paste-support-3236d73d365081c7ac53f983f316e10f)
by [Unito](https://www.unito.io)
2026-03-14 08:06:05 +00:00
Alexander Brown
bb6f00dc68 fix: hide template selector after shared workflow accept (#9913)
## Summary

Hide the template selector when a first-time cloud user accepts a shared
workflow from a share link, so the shared workflow opens without the
onboarding template dialog lingering.

## Changes

- **What**: Added shared-workflow loader behavior to close the global
template selector on accept actions (`copy-and-open` and `open-only`)
while keeping cancel behavior unchanged.
- **What**: Added targeted unit tests covering hide-on-accept and
no-hide-on-cancel behavior in the shared workflow URL loader.

## Review Focus

Confirm that share-link accept paths now dismiss the template selector
and that cancel still leaves it available.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9913-fix-hide-template-selector-after-shared-workflow-accept-3236d73d365081099c04e350d499fad2)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-14 01:05:31 -07:00
Christian Byrne
11fc09220c fix: reorder forwardPanEvent conditions to skip expensive hasTextSelection on non-middle-button events (#9892)
## Summary

Reorder condition checks in `forwardPanEvent` so the cheap
`isMiddlePointerInput()` check runs first, avoiding expensive
`hasTextSelection()` → `window.getSelection().toString().trim()` on
every pointermove event.

## Changes

- **What**: `forwardPanEvent` now early-returns on
`!isMiddlePointerInput(e)` before calling `shouldIgnoreCopyPaste`, which
internally calls `hasTextSelection()`. Since most pointermove events are
not middle-button, this skips the expensive `toString()` call entirely.

## Review Focus

Semantic equivalence: the original condition was `(A && B) || C →
return`. Rewritten as two guards: `if (C) return; if (A && B) return;`.
The logic is equivalent — if `C` (`!isMiddlePointerInput`) is true, we
return regardless of `A && B`. If `C` is false (middle button), we check
`A && B` (`shouldIgnoreCopyPaste && activeElement`).

## Evidence

Backlog item #19. rizumu's Chrome DevTools profiling (Mar 2026) of a
245-node workflow showed `toString()` in `hasTextSelection` called on
every pointermove. Source: Slack `#C095BJSFV24` thread
`p1772823346206479`.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9892-fix-reorder-forwardPanEvent-conditions-to-skip-expensive-hasTextSelection-on-non-middle--3226d73d365081d38b89c8bb1dde3693)
by [Unito](https://www.unito.io)
2026-03-13 19:59:09 -07:00
Terry Jia
16f4f3f3ed fix: address PR review feedback for upstream value composable (#9908)
## Summary
follow up https://github.com/Comfy-Org/ComfyUI_frontend/pull/9851
fix https://github.com/Comfy-Org/ComfyUI_frontend/issues/9877 and
https://github.com/Comfy-Org/ComfyUI_frontend/issues/9878

- Make useUpstreamValue generic to eliminate as Bounds/CurvePoint[]
casts
- Change isBoundsObject to type predicate (value is Bounds)
- Reuse WidgetState from widgetValueStore instead of duplicate interface
- Add length >= 2 guard in isCurvePointArray for empty arrays
- Add disabled guard in effectiveBounds setter
- Add unit tests for singleValueExtractor and boundsExtractor

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9908-fix-address-PR-review-feedback-for-upstream-value-composable-3236d73d365081f7a01dcb416732544a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-13 19:58:30 -07:00
Deep Mehta
4c5a49860c feat: add model-to-node mappings for CogVideo, inpaint, and LayerDiffuse (#9890)
## Summary
- Add `quickRegister()` entries for 3 model directories missing UI
backlinks:
- `CogVideo` → `DownloadAndLoadCogVideoModel` (covers CogVideo,
CogVideo/ControlNet/*, CogVideo/VAE)
  - `inpaint` → `INPAINT_LoadInpaintModel`
  - `layer_model` → `LayeredDiffusionApply`

These mappings ensure the "Use" button in the model browser correctly
creates
the appropriate loader node when users click on models from these node
packs.

## Test plan
- [ ] Verify "Use" button works for CogVideo models in model browser
- [ ] Verify "Use" button works for inpaint models (fooocus, lama, MAT)
- [ ] Verify "Use" button works for LayerDiffuse models

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

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9890-feat-add-model-to-node-mappings-for-CogVideo-inpaint-and-LayerDiffuse-3226d73d3650816ea547fbcdf3a20c35)
by [Unito](https://www.unito.io)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:45:24 -07:00
Johnpaul Chiwetelu
db5e8961e0 chore: upgrade vite to 8.0.0 stable (#9903)
## Summary
- Upgrade Vite from `8.0.0-beta.13` to `8.0.0` stable release

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm build` passes
- [x] `pnpm test:unit` passes (489 files, 6436 tests)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9903-chore-upgrade-vite-to-8-0-0-stable-3226d73d365081feab2af46f0d95d6a4)
by [Unito](https://www.unito.io)
2026-03-13 21:45:41 +00:00
Terry Jia
9f9fa60137 feat: reactive upstream value display for disabled curve and imagecrop widgets (#9851)
Replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/9364

## Summary
Add a generic mechanism for widgets to reactively display upstream
linked values when disabled, with concrete implementations for curve and
imagecrop widgets.

## Changes

- What: When a widget input is linked to an upstream node, the widget
enters a disabled state and displays the upstream node's current value
reactively. This is built as a two-layer system:
- Infrastructure layer: Resolve link origin info (originNodeId,
originOutputName) in buildSlotMetadata, pass it through
SimplifiedWidget.linkedUpstream, and provide a generic useUpstreamValue
composable that reads upstream values from widgetValueStore.
- Widget layer: Each widget type provides its own ValueExtractor to
interpret upstream data. singleValueExtractor handles simple
type-matched values (e.g. CurvePoint[]); boundsExtractor composes a
Bounds object from either a single upstream widget or four individual
x/y/width/height number widgets.
- Curve widget: shows upstream curve points in read-only mode
- ImageCrop widget: shows upstream bounding box with disabled crop
handles and number inputs
- CurveEditor and WidgetBoundingBox: gain disabled prop support

## Adapting future widgets
The system is designed so that any widget needing upstream value display
only needs to:
1. Accept widget: SimplifiedWidget as a prop (provides linkedUpstream
automatically)
2. Call useUpstreamValue(() => widget.linkedUpstream, extractor) with a
suitable extractor
3. Use singleValueExtractor(typeGuard) for single-value types, or write
a custom ValueExtractor for composite cases like boundsExtractor
4. Compute an effectiveValue that switches between upstream and local
based on disabled state

No infrastructure changes are needed — linkedUpstream is already
populated for all widget types that have a corresponding input slot.

## Review Focus
- The buildSlotMetadata helper is shared between extractVueNodeData and
refreshNodeSlots — verify the graph ref is reliably available in both
paths
- boundsExtractor composing from 4 individual number widgets
(x/y/width/height) — this handles the BBox→ImageCrop case where upstream
exposes separate widgets rather than a single Bounds object

## Screenshots

https://github.com/user-attachments/assets/dbc57a44-c5df-44f0-acce-d347797ee8fb

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9851-feat-reactive-upstream-value-display-for-disabled-curve-and-imagecrop-widgets-3226d73d36508134b386ddc9b9f1266b)
by [Unito](https://www.unito.io)
2026-03-13 16:59:53 -04:00
Christian Byrne
7131c274f3 fix: clear stale progress bar on SubgraphNode after navigation (#9865)
## Problem

When navigating back from a subgraph to the root graph, the SubgraphNode
can retain a stale progress bar. This happens because the progress
watcher in `GraphCanvas.vue` watches `[nodeLocationProgressStates,
canvasStore.canvas]`, but neither value changes reference during
subgraph navigation:

- `nodeLocationProgressStates` is already `{}` (execution completed
while viewing the subgraph)
- `canvasStore.canvas` is a `shallowRef` set once at startup — only
`canvas.graph` changes (via `setGraph()`)

**Reproduction** (from PR #4382 comment thread by @guill):
1. Create a subgraph with a KSampler
2. Execute the workflow
3. While progress bar is halfway, enter the subgraph
4. Wait for execution to complete
5. Navigate back to root graph
6. Progress bar is stuck at 50%

## Root Cause

`canvasStore.canvas` is a `shallowRef` — subgraph navigation mutates
`canvas.graph` (a nested property) via `LGraphCanvas.setGraph()`, which
doesn't trigger a shallow watch. The watcher never re-fires to clear
stale `node.progress` values.

## Fix

Add `canvasStore.currentGraph` to the watcher's dependency array. This
is already a `shallowRef` in `canvasStore` that's updated on every
`litegraph:set-graph` event. Zero overhead, precise targeting.

## Context

- Original discussion:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/4382/files/BASE..868e047272f6c5d710db7e607b8997d4c243490f#r2202024855
- PR #9248 correctly removed `deep: true` from this watcher but missed
the subgraph edge case
- `deep: true` was the wrong fix — `canvasStore.currentGraph` is the
precise solution

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9865-fix-clear-stale-progress-bar-on-SubgraphNode-after-navigation-3226d73d3650811c8f9de612d81ef98a)
by [Unito](https://www.unito.io)
2026-03-13 13:04:10 -07:00
woctordho
82556f02a9 fix: respect 'always snap to grid' when auto-scale layout from nodes 1.0 to 2.0 (#9332)
## Summary

Previously when I switch from nodes 1.0 to 2.0, positions and sizes of
nodes do not follow 'always snap to grid'. You can guess what a mess it
is for people relying on snap to grid to retain sanity. This PR fixes
it.

## Changes

In `ensureCorrectLayoutScale`, we call `snapPoint` after the position
and the size are updated.

We also need to ensure that the snapped size is larger than the minimal
size required by the content, so I've added 'ceil' mode to `snapPoint`,
and the patch is larger than I thought first.

I'd happily try out nodes 2.0 once this is addressed :)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9332-fix-respect-always-snap-to-grid-when-auto-scale-layout-from-nodes-1-0-to-2-0-3176d73d365081f5b6bcc035a8ffa648)
by [Unito](https://www.unito.io)
2026-03-13 11:40:51 -07:00
Christian Byrne
e34548724d feat(telemetry): add view_mode and is_app_mode to run_button_click event (#9881)
## Summary

Adds `view_mode` and `is_app_mode` properties to the
`app:run_button_click` telemetry event so analytics can segment run
button clicks by the user's current view context.

## Changes

- **`types.ts`**: Added `view_mode?: string` and `is_app_mode?: boolean`
to `RunButtonProperties`
- **`PostHogTelemetryProvider.ts`**: Computes `view_mode` and
`is_app_mode` from `useAppMode()` in `trackRunButton()`
- **`MixpanelTelemetryProvider.ts`**: Same as PostHog (providers are
mirrors)

## New Properties

| Property | Type | Description | Example Values |
|----------|------|-------------|----------------|
| `view_mode` | `string` | Granular AppMode value | `'graph'`, `'app'`,
`'builder:inputs'`, `'builder:outputs'`, `'builder:arrange'` |
| `is_app_mode` | `boolean` | Simplified flag for app mode vs non-app
mode | `true` when `mode === 'app' \|\| mode === 'builder:arrange'` |

## Design Decisions

- **Both granular and simplified**: `view_mode` gives exact mode for
detailed analysis; `is_app_mode` gives a quick boolean for simple
segmentation
- **Computed in providers**: View mode is read from `useAppMode()` at
tracking time, same pattern as `getExecutionContext()` — no changes
needed at call sites
- **`trigger_source` unchanged**: `keybindingService.ts` already reports
`trigger_source: 'keybinding'` regardless of view mode, satisfying the
requirement that keybinding invocations are correctly identified even in
app mode

## Testing

- Typecheck passes (no new errors)
- Format and lint pass (no new errors)
- Manual verification: all pre-existing errors are in unrelated files
(`draftCacheV2.property.test.ts`, `workflowDraftStoreV2.fsm.test.ts`)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9881-feat-telemetry-add-view_mode-and-is_app_mode-to-run_button_click-event-3226d73d36508101b3a8c7ff27706f81)
by [Unito](https://www.unito.io)
2026-03-13 10:35:13 -07:00
Johnpaul Chiwetelu
fcdc08fb27 feat: structured preload error logging with Sentry enrichment (#8928)
## Summary

Add structured preload error logging with Sentry context enrichment and
a user-facing toast notification when chunk loading fails (e.g. after a
deploy with new hashed filenames).

## Changes

- **`parsePreloadError` utility** (`src/utils/preloadErrorUtil.ts`):
Extracts structured info from `vite:preloadError` events — URL, file
type (JS/CSS/unknown), chunk name, and whether it looks like a hash
mismatch.
- **Sentry enrichment** (`src/App.vue`): Sets Sentry context and tags on
preload errors so they are searchable/filterable in the Sentry
dashboard.
- **User-facing toast**: Shows an actionable "please refresh" message
when a preload error occurs, across all distributions (cloud, desktop,
localhost).
- **Capture-phase resource error listener** (`src/App.vue`): Catches
CSS/script load failures that bypass `vite:preloadError` and reports
them to Sentry with the same structured context.
- **Unit tests** (`src/utils/preloadErrorUtil.test.ts`): 9 tests
covering URL parsing, chunk name extraction, hash mismatch detection,
and edge cases.

## Files Changed

| File | What |
|------|------|
| `src/App.vue` | Preload error handler + resource error listener |
| `src/locales/en/main.json` | Toast message string |
| `src/utils/preloadErrorUtil.ts` | `parsePreloadError()` utility |
| `src/utils/preloadErrorUtil.test.ts` | Unit tests |

## Review Focus

- Toast fires for all distributions (cloud/desktop/localhost) —
intentional so all users see stale chunk errors
- `parsePreloadError` is defensive — returns `unknown` for any field it
cannot parse
- Capture-phase listener filters to only `<script>` and `<link
rel="stylesheet">` elements

## References

- [Vite preload error
handling](https://vite.dev/guide/build#load-error-handling)

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
2026-03-13 10:29:33 -07:00
54 changed files with 3716 additions and 455 deletions

View File

@@ -0,0 +1,77 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe(
'Subgraph progress clear on navigation',
{ tag: ['@subgraph'] },
() => {
test('Stale progress is cleared on subgraph node after navigating back', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
// Find the subgraph node
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
// Simulate a stale progress value on the subgraph node.
// This happens when:
// 1. User views root graph during execution
// 2. Progress watcher sets node.progress = 0.5
// 3. User enters subgraph
// 4. Execution completes (nodeProgressStates becomes {})
// 5. Watcher fires, clears subgraph-internal nodes, but root-level
// SubgraphNode isn't visible so it keeps stale progress
// 6. User navigates back — watcher should fire and clear it
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.5
}, subgraphNodeId!)
// Verify progress is set
const progressBefore = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressBefore).toBe(0.5)
// Navigate into the subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
// Verify we're inside the subgraph
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
// Navigate back to the root graph
await comfyPage.page.keyboard.press('Escape')
await comfyPage.nextFrame()
// The progress watcher should fire when graph changes (because
// nodeLocationProgressStates is empty {} and the watcher should
// iterate canvas.graph.nodes to clear stale node.progress values).
//
// BUG: Without watching canvasStore.currentGraph, the watcher doesn't
// fire on subgraph->root navigation when progress is already empty,
// leaving stale node.progress = 0.5 on the SubgraphNode.
await expect(async () => {
const progressAfter = await comfyPage.page.evaluate((nodeId) => {
return window.app!.canvas.graph!.getNodeById(nodeId)!.progress
}, subgraphNodeId!)
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
}
)

531
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -108,7 +108,7 @@ catalog:
unplugin-icons: ^22.5.0
unplugin-typegpu: 0.8.0
unplugin-vue-components: ^30.0.0
vite: 8.0.0-beta.13
vite: ^8.0.0
vite-plugin-dts: ^4.5.4
vite-plugin-html: ^3.2.2
vite-plugin-vue-devtools: ^8.0.0

View File

@@ -9,15 +9,19 @@ import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
import config from '@/config'
import { isDesktop } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { app } from '@/scripts/app'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI } from '@/utils/envUtil'
import { parsePreloadError } from '@/utils/preloadErrorUtil'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { electronAPI } from '@/utils/envUtil'
import { isDesktop } from '@/platform/distribution/types'
import { app } from '@/scripts/app'
const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
app.extensionManager = useWorkspaceStore()
@@ -45,6 +49,19 @@ const showContextMenu = (event: MouseEvent) => {
}
}
function handleResourceError(url: string, tagName: string) {
console.error('[resource:loadError]', { url, tagName })
if (__DISTRIBUTION__ === 'cloud') {
captureException(new Error(`Resource load failed: ${url}`), {
tags: {
error_type: 'resource_load_error',
tag_name: tagName
}
})
}
}
onMounted(() => {
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
@@ -56,15 +73,56 @@ onMounted(() => {
// See: https://vite.dev/guide/build#load-error-handling
window.addEventListener('vite:preloadError', (event) => {
event.preventDefault()
const info = parsePreloadError(event.payload)
console.error('[vite:preloadError]', {
url: info.url,
fileType: info.fileType,
chunkName: info.chunkName,
message: info.message
})
if (__DISTRIBUTION__ === 'cloud') {
captureException(event.payload, {
tags: { error_type: 'vite_preload_error' }
tags: {
error_type: 'vite_preload_error',
file_type: info.fileType,
chunk_name: info.chunkName ?? undefined
},
contexts: {
preload: {
url: info.url,
fileType: info.fileType,
chunkName: info.chunkName
}
}
})
} else {
console.error('[vite:preloadError]', event.payload)
}
useToastStore().add({
severity: 'error',
summary: t('g.preloadErrorTitle'),
detail: t('g.preloadError'),
life: 10000
})
})
// Capture resource load failures (CSS, scripts) in non-localhost distributions
if (__DISTRIBUTION__ !== 'localhost') {
window.addEventListener(
'error',
(event) => {
const target = event.target
if (target instanceof HTMLScriptElement) {
handleResourceError(target.src, 'script')
} else if (
target instanceof HTMLLinkElement &&
target.rel === 'stylesheet'
) {
handleResourceError(target.href, 'link')
}
},
true
)
}
// Initialize conflict detection in background
// This runs async and doesn't block UI setup
void conflictDetection.initializeConflictDetection()

View File

@@ -3,19 +3,19 @@
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.x') }}
</label>
<ScrubableNumberInput v-model="x" :min="0" :step="1" />
<ScrubableNumberInput v-model="x" :min="0" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.y') }}
</label>
<ScrubableNumberInput v-model="y" :min="0" :step="1" />
<ScrubableNumberInput v-model="y" :min="0" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.width') }}
</label>
<ScrubableNumberInput v-model="width" :min="1" :step="1" />
<ScrubableNumberInput v-model="width" :min="1" :step="1" :disabled />
<label class="content-center text-xs text-node-component-slot-text">
{{ $t('boundingBox.height') }}
</label>
<ScrubableNumberInput v-model="height" :min="1" :step="1" />
<ScrubableNumberInput v-model="height" :min="1" :step="1" :disabled />
</div>
</template>
@@ -25,6 +25,10 @@ import { computed } from 'vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import type { Bounds } from '@/renderer/core/layout/types'
const { disabled = false } = defineProps<{
disabled?: boolean
}>()
const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})

View File

@@ -3,8 +3,13 @@
ref="svgRef"
viewBox="-0.04 -0.04 1.08 1.08"
preserveAspectRatio="xMidYMid meet"
class="aspect-square w-full cursor-crosshair rounded-[5px] bg-node-component-surface"
@pointerdown.stop="handleSvgPointerDown"
:class="
cn(
'aspect-square w-full rounded-[5px] bg-node-component-surface',
disabled ? 'cursor-default' : 'cursor-crosshair'
)
"
@pointerdown.stop="onSvgPointerDown"
@contextmenu.prevent.stop
>
<line
@@ -56,20 +61,23 @@
:stroke="curveColor"
stroke-width="0.008"
stroke-linecap="round"
:opacity="disabled ? 0.5 : 1"
/>
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
<template v-if="!disabled">
<circle
v-for="(point, i) in modelValue"
:key="i"
:cx="point[0]"
:cy="1 - point[1]"
r="0.02"
:fill="curveColor"
stroke="white"
stroke-width="0.004"
class="cursor-grab"
@pointerdown.stop="startDrag(i, $event)"
/>
</template>
</svg>
</template>
@@ -77,14 +85,20 @@
import { computed, useTemplateRef } from 'vue'
import { useCurveEditor } from '@/composables/useCurveEditor'
import { cn } from '@/utils/tailwindUtil'
import type { CurvePoint } from './types'
import { histogramToPath } from './curveUtils'
const { curveColor = 'white', histogram } = defineProps<{
const {
curveColor = 'white',
histogram,
disabled = false
} = defineProps<{
curveColor?: string
histogram?: Uint32Array | null
disabled?: boolean
}>()
const modelValue = defineModel<CurvePoint[]>({
@@ -98,6 +112,10 @@ const { curvePath, handleSvgPointerDown, startDrag } = useCurveEditor({
modelValue
})
function onSvgPointerDown(e: PointerEvent) {
if (!disabled) handleSvgPointerDown(e)
}
const histogramPath = computed(() =>
histogram ? histogramToPath(histogram) : ''
)

View File

@@ -1,11 +1,27 @@
<template>
<CurveEditor v-model="modelValue" />
<CurveEditor
:model-value="effectivePoints"
:disabled="isDisabled"
@update:model-value="modelValue = $event"
/>
</template>
<script setup lang="ts">
import type { CurvePoint } from './types'
import { computed } from 'vue'
import {
singleValueExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import CurveEditor from './CurveEditor.vue'
import { isCurvePointArray } from './curveUtils'
import type { CurvePoint } from './types'
const { widget } = defineProps<{
widget: SimplifiedWidget
}>()
const modelValue = defineModel<CurvePoint[]>({
default: () => [
@@ -13,4 +29,17 @@ const modelValue = defineModel<CurvePoint[]>({
[1, 1]
]
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
singleValueExtractor(isCurvePointArray)
)
const effectivePoints = computed(() =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value
)
</script>

View File

@@ -1,5 +1,19 @@
import type { CurvePoint } from './types'
export function isCurvePointArray(value: unknown): value is CurvePoint[] {
return (
Array.isArray(value) &&
value.length >= 2 &&
value.every(
(p) =>
Array.isArray(p) &&
p.length === 2 &&
typeof p[0] === 'number' &&
typeof p[1] === 'number'
)
)
}
/**
* Monotone cubic Hermite interpolation.
* Produces a smooth curve that passes through all control points

View File

@@ -381,10 +381,17 @@ watch(
* No `deep: true` needed — `nodeLocationProgressStates` is a computed that
* returns a new `Record` object on every progress event (the underlying
* `nodeProgressStates` ref is replaced wholesale by the WebSocket handler).
*
* `currentGraph` triggers this watcher on subgraph navigation so stale
* progress bars are cleared when returning to the root graph.
*/
watch(
() =>
[executionStore.nodeLocationProgressStates, canvasStore.canvas] as const,
[
executionStore.nodeLocationProgressStates,
canvasStore.canvas,
canvasStore.currentGraph
] as const,
([nodeLocationProgressStates, canvas]) => {
if (!canvas?.graph) return
for (const node of canvas.graph.nodes) {
@@ -563,10 +570,8 @@ onUnmounted(() => {
vueNodeLifecycle.cleanup()
})
function forwardPanEvent(e: PointerEvent) {
if (
(shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target) ||
!isMiddlePointerInput(e)
)
if (!isMiddlePointerInput(e)) return
if (shouldIgnoreCopyPaste(e.target) && document.activeElement === e.target)
return
canvasInteractions.forwardEventToCanvas(e)

View File

@@ -36,26 +36,37 @@
<div
v-if="imageUrl && !isLoading"
class="absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
:class="
cn(
'absolute box-content cursor-move border-2 border-white shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]',
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="cropBoxStyle"
@pointerdown="handleDragStart"
@pointermove="handleDragMove"
@pointerup="handleDragEnd"
/>
<div
v-for="handle in resizeHandles"
v-show="imageUrl && !isLoading"
:key="handle.direction"
:class="['absolute', handle.class]"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
<template v-for="handle in resizeHandles" :key="handle.direction">
<div
v-show="imageUrl && !isLoading"
:class="
cn(
'absolute',
handle.class,
isDisabled && 'pointer-events-none opacity-60'
)
"
:style="handle.style"
@pointerdown="(e) => handleResizeStart(e, handle.direction)"
@pointermove="handleResizeMove"
@pointerup="handleResizeEnd"
/>
</template>
</div>
<div class="flex shrink-0 items-center gap-2">
<div v-if="!isDisabled" class="flex shrink-0 items-center gap-2">
<label class="text-xs text-muted-foreground">
{{ $t('imageCrop.ratio') }}
</label>
@@ -90,12 +101,16 @@
</Button>
</div>
<WidgetBoundingBox v-model="modelValue" class="shrink-0" />
<WidgetBoundingBox
v-model="effectiveBounds"
:disabled="isDisabled"
class="shrink-0"
/>
</div>
</template>
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'
import WidgetBoundingBox from '@/components/boundingbox/WidgetBoundingBox.vue'
import Button from '@/components/ui/button/Button.vue'
@@ -105,10 +120,17 @@ import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { ASPECT_RATIOS, useImageCrop } from '@/composables/useImageCrop'
import {
boundsExtractor,
useUpstreamValue
} from '@/composables/useUpstreamValue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { Bounds } from '@/renderer/core/layout/types'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const { widget, nodeId } = defineProps<{
widget: SimplifiedWidget
nodeId: NodeId
}>()
@@ -116,6 +138,23 @@ const modelValue = defineModel<Bounds>({
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
})
const isDisabled = computed(() => !!widget.options?.disabled)
const upstreamValue = useUpstreamValue(
() => widget.linkedUpstream,
boundsExtractor()
)
const effectiveBounds = computed({
get: () =>
isDisabled.value && upstreamValue.value
? upstreamValue.value
: modelValue.value,
set: (v) => {
if (!isDisabled.value) modelValue.value = v
}
})
const imageEl = useTemplateRef<HTMLImageElement>('imageEl')
const containerEl = useTemplateRef<HTMLDivElement>('containerEl')
@@ -139,5 +178,5 @@ const {
handleResizeStart,
handleResizeMove,
handleResizeEnd
} = useImageCrop(props.nodeId, { imageEl, containerEl, modelValue })
} = useImageCrop(nodeId, { imageEl, containerEl, modelValue: effectiveBounds })
</script>

View File

@@ -23,6 +23,7 @@ import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
import { getStableWidgetRenderKey } from '@/core/graph/subgraph/widgetRenderKey'
const {
label,
@@ -272,7 +273,7 @@ defineExpose({
<TransitionGroup name="list-scale">
<WidgetItem
v-for="{ widget, node } in widgets"
:key="`${node.id}-${widget.name}-${widget.type}`"
:key="getStableWidgetRenderKey(widget)"
:widget="widget"
:node="node"
:is-draggable="isDraggable"

View File

@@ -241,6 +241,88 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
const graph = subgraphNode.graph
if (!graph) throw new Error('Expected subgraph node graph')
graph.add(subgraphNode)
const promotedViews = subgraphNode.widgets
const secondPromotedView = promotedViews[1]
if (!secondPromotedView) throw new Error('Expected second promoted view')
;(
secondPromotedView as unknown as {
sourceNodeId: string
sourceWidgetName: string
}
).sourceNodeId = '9999'
;(
secondPromotedView as unknown as {
sourceNodeId: string
sourceWidgetName: string
}
).sourceWidgetName = 'stale_widget'
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const secondMappedWidget = nodeData?.widgets?.find(
(widget) => widget.slotMetadata?.index === 1
)
if (!secondMappedWidget)
throw new Error('Expected mapped widget for slot 1')
expect(secondMappedWidget.name).not.toBe('stale_widget')
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
})
})
describe('Subgraph output slot label reactivity', () => {
@@ -390,6 +472,56 @@ describe('Nested promoted widget mapping', () => {
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})
it('keeps linked and independent same-name promotions as distinct sources', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget(
'text',
'string_a',
'independent',
() => undefined,
{}
)
subgraph.add(independentNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'string_a'
)
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${subgraph.id}:${linkedNode.id}`,
`${subgraph.id}:${independentNode.id}`
])
)
})
})
describe('Promoted widget sourceExecutionId', () => {

View File

@@ -7,6 +7,7 @@ import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
@@ -41,6 +42,8 @@ import { getExecutionIdByNode } from '@/utils/graphTraversalUtil'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: string
originOutputName?: string
}
/**
@@ -242,16 +245,17 @@ function safeWidgetMapper(
}
}
const promotedInputName = node.inputs?.find((input) => {
if (input.name === widget.name) return true
if (input._widget === widget) return true
return false
})?.name
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const promotedSource = resolvePromotedSourceByInputName(displayName) ?? {
const directSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
: directSource
return {
displayName,
@@ -355,6 +359,36 @@ function safeWidgetMapper(
}
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: string | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
if (link) {
originNodeId = String(link.origin_id)
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
@@ -427,15 +461,11 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
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)
})
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
@@ -488,22 +518,11 @@ 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>()
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)
})
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// 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
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
}
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
}
const isNumber = (v: unknown): v is number => typeof v === 'number'
describe('singleValueExtractor', () => {
const extract = singleValueExtractor(isNumber)
it('matches widget by outputName', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, 'b')).toBe(42)
})
it('returns undefined when outputName widget has invalid value', () => {
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
expect(extract(widgets, 'b')).toBeUndefined()
})
it('falls back to unique valid widget when outputName has no match', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, 'missing')).toBe(42)
})
it('falls back to unique valid widget when no outputName provided', () => {
const widgets = [widget('a', 'text'), widget('b', 42)]
expect(extract(widgets, undefined)).toBe(42)
})
it('returns undefined when multiple widgets have valid values', () => {
const widgets = [widget('a', 1), widget('b', 2)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when no widgets have valid values', () => {
const widgets = [widget('a', 'text')]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
})
})
describe('boundsExtractor', () => {
const extract = boundsExtractor()
it('extracts a single bounds object widget', () => {
const bounds = { x: 10, y: 20, width: 100, height: 200 }
const widgets = [widget('crop', bounds)]
expect(extract(widgets, undefined)).toEqual(bounds)
})
it('matches bounds widget by outputName', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [widget('other', 'text'), widget('crop', bounds)]
expect(extract(widgets, 'crop')).toEqual(bounds)
})
it('assembles bounds from individual x/y/width/height widgets', () => {
const widgets = [
widget('x', 10),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
expect(extract(widgets, undefined)).toEqual({
x: 10,
y: 20,
width: 100,
height: 200
})
})
it('returns undefined when some bound components are missing', () => {
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined when bound components have wrong types', () => {
const widgets = [
widget('x', '10'),
widget('y', 20),
widget('width', 100),
widget('height', 200)
]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('returns undefined for empty widgets', () => {
expect(extract([], undefined)).toBeUndefined()
})
it('rejects partial bounds objects', () => {
const partial = { x: 10, y: 20 }
const widgets = [widget('crop', partial)]
expect(extract(widgets, undefined)).toBeUndefined()
})
it('prefers single bounds object over individual widgets', () => {
const bounds = { x: 1, y: 2, width: 3, height: 4 }
const widgets = [
widget('crop', bounds),
widget('x', 99),
widget('y', 99),
widget('width', 99),
widget('height', 99)
]
expect(extract(widgets, undefined)).toEqual(bounds)
})
})

View File

@@ -0,0 +1,80 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],
outputName: string | undefined
) => T | undefined
export function useUpstreamValue<T>(
getLinkedUpstream: () => LinkedUpstreamInfo | undefined,
extractValue: ValueExtractor<T>
) {
const canvasStore = useCanvasStore()
const widgetValueStore = useWidgetValueStore()
return computed(() => {
const upstream = getLinkedUpstream()
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
return extractValue(widgets, upstream.outputName)
})
}
export function singleValueExtractor<T>(
isValid: (value: unknown) => value is T
): ValueExtractor<T> {
return (widgets, outputName) => {
if (outputName) {
const matched = widgets.find((w) => w.name === outputName)
if (matched && isValid(matched.value)) return matched.value
}
const validValues = widgets.map((w) => w.value).filter(isValid)
return validValues.length === 1 ? validValues[0] : undefined
}
}
function isBoundsObject(value: unknown): value is Bounds {
if (typeof value !== 'object' || value === null) return false
const v = value as Record<string, unknown>
return (
typeof v.x === 'number' &&
typeof v.y === 'number' &&
typeof v.width === 'number' &&
typeof v.height === 'number'
)
}
export function boundsExtractor(): ValueExtractor<Bounds> {
const single = singleValueExtractor(isBoundsObject)
return (widgets, outputName) => {
const singleResult = single(widgets, outputName)
if (singleResult) return singleResult
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
const getNum = (name: string): number | undefined => {
const w = widgets.find((w) => w.name === name)
return typeof w?.value === 'number' ? w.value : undefined
}
const x = getNum('x')
const y = getNum('y')
const width = getNum('width')
const height = getNum('height')
if (
x !== undefined &&
y !== undefined &&
width !== undefined &&
height !== undefined
) {
return { x, y, width, height }
}
return undefined
}
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { matchPromotedInput } from './matchPromotedInput'
type MockInput = {
name: string
_widget?: IBaseWidget
}
function createWidget(name: string): IBaseWidget {
return {
name,
type: 'text'
} as IBaseWidget
}
describe(matchPromotedInput, () => {
it('prefers exact _widget matches before same-name inputs', () => {
const targetWidget = createWidget('seed')
const aliasWidget = createWidget('seed')
const aliasInput: MockInput = {
name: 'seed',
_widget: aliasWidget
}
const exactInput: MockInput = {
name: 'seed',
_widget: targetWidget
}
const matched = matchPromotedInput(
[aliasInput, exactInput] as unknown as Array<{
name: string
_widget?: IBaseWidget
}>,
targetWidget
)
expect(matched).toBe(exactInput)
})
it('falls back to same-name matching when no exact widget match exists', () => {
const targetWidget = createWidget('seed')
const aliasInput: MockInput = {
name: 'seed'
}
const matched = matchPromotedInput(
[aliasInput] as unknown as Array<{ name: string; _widget?: IBaseWidget }>,
targetWidget
)
expect(matched).toBe(aliasInput)
})
it('does not guess when multiple same-name inputs exist without an exact match', () => {
const targetWidget = createWidget('seed')
const firstAliasInput: MockInput = {
name: 'seed'
}
const secondAliasInput: MockInput = {
name: 'seed'
}
const matched = matchPromotedInput(
[firstAliasInput, secondAliasInput] as unknown as Array<{
name: string
_widget?: IBaseWidget
}>,
targetWidget
)
expect(matched).toBeUndefined()
})
})

View File

@@ -0,0 +1,19 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type PromotedInputLike = {
name: string
_widget?: IBaseWidget
}
export function matchPromotedInput(
inputs: PromotedInputLike[] | undefined,
widget: IBaseWidget
): PromotedInputLike | undefined {
if (!inputs) return undefined
const exactMatch = inputs.find((input) => input._widget === widget)
if (exactMatch) return exactMatch
const sameNameMatches = inputs.filter((input) => input.name === widget.name)
return sameNameMatches.length === 1 ? sameNameMatches[0] : undefined
}

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
@@ -11,19 +11,26 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointerEvent,
LGraphCanvas,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import {
cleanupComplexPromotionFixtureNodeType,
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
setupComplexPromotionFixture
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -78,6 +85,22 @@ function firstInnerNode(innerNodes: LGraphNode[]): LGraphNode {
return innerNode
}
function promotedWidgets(node: SubgraphNode): PromotedWidgetView[] {
return node.widgets as PromotedWidgetView[]
}
function callSyncPromotions(node: SubgraphNode) {
;(
node as unknown as {
_syncPromotions: () => void
}
)._syncPromotions()
}
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
describe(createPromotedWidgetView, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -263,6 +286,31 @@ describe(createPromotedWidgetView, () => {
expect(fallbackWidget.value).toBe('updated')
})
test('value setter falls back to host widget when linked states are unavailable', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'initial', () => {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const linkedView = promotedWidgets(subgraphNode)[0]
if (!linkedView) throw new Error('Expected a linked promoted widget')
const widgetValueStore = useWidgetValueStore()
vi.spyOn(widgetValueStore, 'getWidget').mockReturnValue(undefined)
linkedView.value = 'updated'
expect(linkedNode.widgets?.[0].value).toBe('updated')
})
test('label falls back to displayName then widgetName', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
@@ -495,6 +543,185 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('renders all promoted widgets when duplicate input names are connected to different nodes', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 94 })
subgraphNode.graph?.add(subgraphNode)
const firstNode = new LGraphNode('FirstSeedNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondSeedNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(2)
expect(widgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
String(firstNode.id),
String(secondNode.id)
])
})
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 95 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const promotedNode = new LGraphNode('PromotedNode')
promotedNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(promotedNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(promotedNode.id),
'string_a'
)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(linkedNodeA.id),
'string_a'
)
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(2)
const linkedView = widgets.find(
(widget) => widget.sourceNodeId === String(linkedNodeA.id)
)
const promotedView = widgets.find(
(widget) => widget.sourceNodeId === String(promotedNode.id)
)
if (!linkedView || !promotedView)
throw new Error(
'Expected linked and store-promoted widgets to be present'
)
linkedView.value = 'shared-value'
const widgetStore = useWidgetValueStore()
const graphId = subgraphNode.rootGraph.id
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent')
promotedView.value = 'independent-updated'
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent-updated')
})
test('duplicate-name promoted views map slot linkage by view identity', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'linked', () => {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(independentNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
const widgets = promotedWidgets(subgraphNode)
const linkedView = widgets.find(
(widget) => widget.sourceNodeId === String(linkedNode.id)
)
const independentView = widgets.find(
(widget) => widget.sourceNodeId === String(independentNode.id)
)
if (!linkedView || !independentView)
throw new Error('Expected linked and independent promoted views')
const linkedSlot = subgraphNode.getSlotFromWidget(linkedView)
const independentSlot = subgraphNode.getSlotFromWidget(independentView)
expect(linkedSlot).toBeDefined()
expect(independentSlot).toBeUndefined()
})
test('returns empty array when no proxyWidgets', () => {
const [subgraphNode] = setupSubgraph()
expect(subgraphNode.widgets).toEqual([])
@@ -558,6 +785,273 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('full linked coverage does not prune unresolved independent fallback promotions', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widgetA', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 125 })
subgraphNode.graph?.add(subgraphNode)
const liveNode = new LGraphNode('LiveNode')
const liveInput = liveNode.addInput('widgetA', '*')
liveNode.addWidget('text', 'widgetA', 'a', () => {})
liveInput.widget = { name: 'widgetA' }
subgraph.add(liveNode)
subgraph.inputNode.slots[0].connect(liveInput, liveNode)
setPromotions(subgraphNode, [
[String(liveNode.id), 'widgetA'],
['9999', 'widgetA']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetA' }
])
})
test('input-added existing-input path tolerates missing link metadata', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'widgetA', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 126 })
subgraphNode.graph?.add(subgraphNode)
const existingSlot = subgraph.inputNode.slots[0]
if (!existingSlot) throw new Error('Expected subgraph input slot')
expect(() => {
subgraph.events.dispatch('input-added', { input: existingSlot })
}).not.toThrow()
})
test('syncPromotions prunes stale connected entries but keeps independent promotions', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 96 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(independentNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
setPromotions(subgraphNode, [
[String(independentNode.id), 'string_a'],
[String(linkedNodeA.id), 'string_a'],
[String(linkedNodeB.id), 'string_a']
])
callSyncPromotions(subgraphNode)
const promotions = usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
])
})
test('syncPromotions prunes stale deep-alias entries for nested linked promotions', () => {
const { subgraphNodeB } = createTwoLevelNestedSubgraph()
const linkedView = promotedWidgets(subgraphNodeB)[0]
if (!linkedView)
throw new Error(
'Expected nested subgraph to expose a linked promoted view'
)
const concrete = resolveConcretePromotedWidget(
subgraphNodeB,
linkedView.sourceNodeId,
linkedView.sourceWidgetName
)
if (concrete.status !== 'resolved')
throw new Error(
'Expected nested promoted view to resolve to concrete widget'
)
const linkedEntry = [
linkedView.sourceNodeId,
linkedView.sourceWidgetName
] as [string, string]
const deepAliasEntry = [
String(concrete.resolved.node.id),
concrete.resolved.widget.name
] as [string, string]
// Guardrail: this test specifically validates host/deep alias cleanup.
expect(deepAliasEntry).not.toStrictEqual(linkedEntry)
setPromotions(subgraphNodeB, [linkedEntry, deepAliasEntry])
callSyncPromotions(subgraphNodeB)
const promotions = usePromotionStore().getPromotions(
subgraphNodeB.rootGraph.id,
subgraphNodeB.id
)
expect(promotions).toStrictEqual([
{
interiorNodeId: linkedEntry[0],
widgetName: linkedEntry[1]
}
])
})
test('configure prunes stale disconnected host aliases that resolve to the active linked concrete widget', () => {
const nestedSubgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('string_a', '*')
concreteNode.addWidget('text', 'string_a', 'value', () => {})
concreteInput.widget = { name: 'string_a' }
nestedSubgraph.add(concreteNode)
nestedSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
const hostSubgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const activeAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 118 })
const staleAliasNode = createTestSubgraphNode(nestedSubgraph, { id: 119 })
hostSubgraph.add(activeAliasNode)
hostSubgraph.add(staleAliasNode)
activeAliasNode._internalConfigureAfterSlots()
staleAliasNode._internalConfigureAfterSlots()
hostSubgraph.inputNode.slots[0].connect(
activeAliasNode.inputs[0],
activeAliasNode
)
const hostSubgraphNode = createTestSubgraphNode(hostSubgraph, { id: 120 })
hostSubgraphNode.graph?.add(hostSubgraphNode)
setPromotions(hostSubgraphNode, [
[String(activeAliasNode.id), 'string_a'],
[String(staleAliasNode.id), 'string_a']
])
const serialized = hostSubgraphNode.serialize()
const restoredNode = createTestSubgraphNode(hostSubgraph, { id: 121 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: hostSubgraph.id,
inputs: []
})
const restoredPromotions = usePromotionStore().getPromotions(
restoredNode.rootGraph.id,
restoredNode.id
)
expect(restoredPromotions).toStrictEqual([
{
interiorNodeId: String(activeAliasNode.id),
widgetName: 'string_a'
}
])
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(1)
expect(restoredWidgets[0].sourceNodeId).toBe(String(activeAliasNode.id))
})
test('serialize syncs duplicate-name linked inputs by subgraph slot identity', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'seed', type: '*' },
{ name: 'seed', type: '*' }
]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 127 })
subgraphNode.graph?.add(subgraphNode)
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('text', 'seed', 'first-initial', () => {})
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('text', 'seed', 'second-initial', () => {})
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const widgets = promotedWidgets(subgraphNode)
const firstView = widgets[0]
const secondView = widgets[1]
if (!firstView || !secondView)
throw new Error('Expected two linked promoted views')
firstView.value = 'first-updated'
secondView.value = 'second-updated'
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
subgraphNode.serialize()
expect(firstNode.widgets?.[0].value).toBe('first-updated')
expect(secondNode.widgets?.[0].value).toBe('second-updated')
})
test('renaming an input updates linked promoted view display names', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 128 })
subgraphNode.graph?.add(subgraphNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('seed', '*')
linkedNode.addWidget('text', 'seed', 'value', () => {})
linkedInput.widget = { name: 'seed' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const beforeRename = promotedWidgets(subgraphNode)[0]
if (!beforeRename) throw new Error('Expected linked promoted view')
expect(beforeRename.name).toBe('seed')
const inputToRename = subgraph.inputs[0]
if (!inputToRename) throw new Error('Expected input to rename')
subgraph.renameInput(inputToRename, 'seed_renamed')
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
expect(afterRename.name).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
@@ -701,6 +1195,236 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('configure with empty serialized inputs keeps linked filtering active', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(storeOnlyNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
setPromotions(subgraphNode, [
[String(linkedNodeA.id), 'string_a'],
[String(linkedNodeB.id), 'string_a'],
[String(storeOnlyNode.id), 'string_a']
])
const serialized = subgraphNode.serialize()
const restoredNode = createTestSubgraphNode(subgraph, { id: 98 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: subgraph.id,
inputs: []
})
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(2)
const linkedViewCount = restoredWidgets.filter((widget) =>
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
widget.sourceNodeId
)
).length
expect(linkedViewCount).toBe(1)
expect(
restoredWidgets.some(
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
)
).toBe(true)
})
test('configure with serialized inputs rebinds subgraph slots for linked filtering', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 107 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
const storeOnlyNode = new LGraphNode('StoreOnlyNode')
storeOnlyNode.addWidget('text', 'string_a', 'independent', () => {})
subgraph.add(storeOnlyNode)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
setPromotions(subgraphNode, [
[String(linkedNodeA.id), 'string_a'],
[String(linkedNodeB.id), 'string_a'],
[String(storeOnlyNode.id), 'string_a']
])
const serialized = subgraphNode.serialize()
const restoredNode = createTestSubgraphNode(subgraph, { id: 108 })
restoredNode.configure({
...serialized,
id: restoredNode.id,
type: subgraph.id,
inputs: [
{
name: 'string_a',
type: '*',
link: null
}
]
})
const restoredWidgets = promotedWidgets(restoredNode)
expect(restoredWidgets).toHaveLength(2)
const linkedViewCount = restoredWidgets.filter((widget) =>
[String(linkedNodeA.id), String(linkedNodeB.id)].includes(
widget.sourceNodeId
)
).length
expect(linkedViewCount).toBe(1)
expect(
restoredWidgets.some(
(widget) => widget.sourceNodeId === String(storeOnlyNode.id)
)
).toBe(true)
})
test('fixture keeps earliest linked representative and independent promotion only', () => {
const { graph, hostNode } = setupComplexPromotionFixture()
const hostWidgets = promotedWidgets(hostNode)
expect(hostWidgets).toHaveLength(2)
expect(hostWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
expect(promotions).toStrictEqual([
{ interiorNodeId: '20', widgetName: 'string_a' },
{ interiorNodeId: '19', widgetName: 'string_a' }
])
const linkedView = hostWidgets[0]
const independentView = hostWidgets[1]
if (!linkedView || !independentView)
throw new Error('Expected linked and independent promoted widgets')
independentView.value = 'independent-value'
linkedView.value = 'shared-linked'
const widgetStore = useWidgetValueStore()
const getValue = (nodeId: string) =>
widgetStore.getWidget(graph.id, stripGraphPrefix(nodeId), 'string_a')
?.value
expect(getValue('20')).toBe('shared-linked')
expect(getValue('18')).toBe('shared-linked')
expect(getValue('19')).toBe('independent-value')
})
test('fixture refreshes duplicate fallback after linked representative recovers', () => {
const { subgraph, hostNode } = setupComplexPromotionFixture()
const earliestLinkedNode = subgraph.getNodeById(20)
if (!earliestLinkedNode?.widgets)
throw new Error('Expected fixture to contain node 20 with widgets')
const originalWidgets = earliestLinkedNode.widgets
earliestLinkedNode.widgets = originalWidgets.filter(
(widget) => widget.name !== 'string_a'
)
const unresolvedWidgets = promotedWidgets(hostNode)
expect(
unresolvedWidgets.map((widget) => widget.sourceNodeId)
).toStrictEqual(['18', '20', '19'])
earliestLinkedNode.widgets = originalWidgets
const restoredWidgets = promotedWidgets(hostNode)
expect(restoredWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
})
test('fixture converges external widgets and keeps rendered value isolation after transient linked fallback churn', () => {
const { subgraph, hostNode } = setupComplexPromotionFixture()
const initialWidgets = promotedWidgets(hostNode)
expect(initialWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
const earliestLinkedNode = subgraph.getNodeById(20)
if (!earliestLinkedNode?.widgets)
throw new Error('Expected fixture to contain node 20 with widgets')
const originalWidgets = earliestLinkedNode.widgets
earliestLinkedNode.widgets = originalWidgets.filter(
(widget) => widget.name !== 'string_a'
)
const transientWidgets = promotedWidgets(hostNode)
expect(transientWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual(
['18', '20', '19']
)
earliestLinkedNode.widgets = originalWidgets
const finalWidgets = promotedWidgets(hostNode)
expect(finalWidgets).toHaveLength(2)
expect(finalWidgets.map((widget) => widget.sourceNodeId)).toStrictEqual([
'20',
'19'
])
const finalLinkedView = finalWidgets.find(
(widget) => widget.sourceNodeId === '20'
)
const finalIndependentView = finalWidgets.find(
(widget) => widget.sourceNodeId === '19'
)
if (!finalLinkedView || !finalIndependentView)
throw new Error('Expected final rendered linked and independent views')
finalIndependentView.value = 'independent-final'
expect(finalIndependentView.value).toBe('independent-final')
expect(finalLinkedView.value).not.toBe('independent-final')
finalLinkedView.value = 'linked-final'
expect(finalLinkedView.value).toBe('linked-final')
expect(finalIndependentView.value).toBe('independent-final')
})
test('clone output preserves proxyWidgets for promotion hydration', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
@@ -751,6 +1475,103 @@ describe('widgets getter caching', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
}>
) => unknown
},
'_buildPromotionReconcileState'
)
void subgraphNode.widgets
void subgraphNode.widgets
void subgraphNode.widgets
expect(reconcileSpy).toHaveBeenCalledTimes(1)
})
test('does not re-run reconciliation when only canvas frame advances', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
}>
) => unknown
},
'_buildPromotionReconcileState'
)
void subgraphNode.widgets
fakeCanvas.frame += 1
void subgraphNode.widgets
expect(reconcileSpy).toHaveBeenCalledTimes(1)
})
test('does not re-resolve linked entries when linked input state is unchanged', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 97 })
subgraphNode.graph?.add(subgraphNode)
const linkedNodeA = new LGraphNode('LinkedNodeA')
const linkedInputA = linkedNodeA.addInput('string_a', '*')
linkedNodeA.addWidget('text', 'string_a', 'a', () => {})
linkedInputA.widget = { name: 'string_a' }
subgraph.add(linkedNodeA)
const linkedNodeB = new LGraphNode('LinkedNodeB')
const linkedInputB = linkedNodeB.addInput('string_a', '*')
linkedNodeB.addWidget('text', 'string_a', 'b', () => {})
linkedInputB.widget = { name: 'string_a' }
subgraph.add(linkedNodeB)
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
const resolveSpy = vi.spyOn(
subgraphNode as unknown as {
_resolveLinkedPromotionBySubgraphInput: (...args: unknown[]) => unknown
},
'_resolveLinkedPromotionBySubgraphInput'
)
void subgraphNode.widgets
const initialResolveCount = resolveSpy.mock.calls.length
expect(initialResolveCount).toBeLessThanOrEqual(1)
void subgraphNode.widgets
expect(resolveSpy).toHaveBeenCalledTimes(initialResolveCount)
})
test('preserves view identities when promotion order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})

View File

@@ -1,4 +1,4 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
@@ -13,11 +13,15 @@ import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
@@ -131,6 +135,38 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
set value(value: IBaseWidget['value']) {
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
let didUpdateState = false
for (const linkedWidget of linkedWidgets) {
const state = widgetStore.getWidget(
this.graphId,
linkedWidget.nodeId,
linkedWidget.widgetName
)
if (state) {
state.value = value
didUpdateState = true
}
}
const resolved = this.resolveDeepest()
if (resolved) {
const resolvedState = widgetStore.getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (resolvedState) {
resolvedState.value = value
didUpdateState = true
}
}
if (didUpdateState) return
}
const state = this.getWidgetState()
if (state) {
state.value = value
@@ -278,6 +314,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
@@ -287,6 +326,57 @@ class PromotedWidgetView implements IPromotedWidgetView {
)
}
private getLinkedInputWidgets(): Array<{
nodeId: NodeId
widgetName: string
widget: IBaseWidget
}> {
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
if (!input._subgraphSlot) return false
if (matchPromotedInput([input], this) !== input) return false
const boundWidget = input._widget
if (boundWidget === this) return true
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
)
}
return input._subgraphSlot
.getConnectedWidgets()
.filter(hasWidgetNode)
.some(
(widget) =>
String(widget.node.id) === this.sourceNodeId &&
widget.name === this.sourceWidgetName
)
})
const linkedInput = linkedInputSlot?._subgraphSlot
if (!linkedInput) return []
return linkedInput
.getConnectedWidgets()
.filter(hasWidgetNode)
.map((widget) => ({
nodeId: stripGraphPrefix(String(widget.node.id)),
widgetName: widget.name,
widget
}))
}
private getLinkedInputWidgetStates(): WidgetState[] {
const widgetStore = useWidgetValueStore()
return this.getLinkedInputWidgets()
.map(({ nodeId, widgetName }) =>
widgetStore.getWidget(this.graphId, nodeId, widgetName)
)
.filter((state): state is WidgetState => state !== undefined)
}
private getProjectedWidget(resolved: {
node: LGraphNode
widget: IBaseWidget

View File

@@ -0,0 +1,8 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function hasWidgetNode(
widget: IBaseWidget
): widget is IBaseWidget & { node: LGraphNode } {
return 'node' in widget && !!widget.node
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { getStableWidgetRenderKey } from './widgetRenderKey'
function createWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
return {
name: 'seed',
type: 'number',
...overrides
} as IBaseWidget
}
describe(getStableWidgetRenderKey, () => {
it('returns a stable key for the same widget instance', () => {
const widget = createWidget()
const first = getStableWidgetRenderKey(widget)
const second = getStableWidgetRenderKey(widget)
expect(second).toBe(first)
})
it('returns distinct keys for distinct widget instances', () => {
const firstWidget = createWidget()
const secondWidget = createWidget()
const firstKey = getStableWidgetRenderKey(firstWidget)
const secondKey = getStableWidgetRenderKey(secondWidget)
expect(secondKey).not.toBe(firstKey)
})
})

View File

@@ -0,0 +1,17 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
const widgetRenderKeys = new WeakMap<IBaseWidget, string>()
let nextWidgetRenderKeyId = 0
export function getStableWidgetRenderKey(widget: IBaseWidget): string {
const cachedKey = widgetRenderKeys.get(widget)
if (cachedKey) return cachedKey
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
const key = `${prefix}:${nextWidgetRenderKeyId++}`
widgetRenderKeys.set(widget, key)
return key
}

View File

@@ -45,7 +45,8 @@ import { LiteGraph, SubgraphNode } from './litegraph'
import {
alignOutsideContainer,
alignToContainer,
createBounds
createBounds,
snapPoint
} from './measure'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
@@ -2594,7 +2595,18 @@ export class LGraph
// configure nodes afterwards so they can reach each other
for (const [id, nodeData] of nodeDataMap) {
this.getNodeById(id)?.configure(nodeData)
const node = this.getNodeById(id)
node?.configure(nodeData)
if (LiteGraph.alwaysSnapToGrid && node) {
const snapTo = this.getSnapToGridSize()
if (node.snapToGrid(snapTo)) {
// snapToGrid mutates the internal _pos array in-place, bypassing the setter
// This reassignment triggers the pos setter to sync to the Vue layout store
node.pos = [node.pos[0], node.pos[1]]
}
snapPoint(node.size, snapTo, 'ceil')
}
}
}

View File

@@ -2959,6 +2959,11 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
// Enforce minimum size
const min = node.computeSize()
if (this._snapToGrid) {
// Previously newBounds.size is snapped with 'round'
// Now the minimum size is snapped with 'ceil' to avoid clipping
snapPoint(min, this._snapToGrid, 'ceil')
}
if (newBounds.width < min[0]) {
// If resizing from left, adjust position to maintain right edge
if (resizeDirection.includes('W')) {

View File

@@ -130,6 +130,34 @@ test('snapPoint correctly snaps points to grid', ({ expect }) => {
expect(point3).toEqual([20, 20])
})
test('snapPoint correctly snaps points to grid using ceil', ({ expect }) => {
const point: Point = [12.3, 18.7]
expect(snapPoint(point, 5, 'ceil')).toBe(true)
expect(point).toEqual([15, 20])
const point2: Point = [15, 20]
expect(snapPoint(point2, 5, 'ceil')).toBe(true)
expect(point2).toEqual([15, 20])
const point3: Point = [15.1, -18.7]
expect(snapPoint(point3, 10, 'ceil')).toBe(true)
expect(point3).toEqual([20, -10])
})
test('snapPoint correctly snaps points to grid using floor', ({ expect }) => {
const point: Point = [12.3, 18.7]
expect(snapPoint(point, 5, 'floor')).toBe(true)
expect(point).toEqual([10, 15])
const point2: Point = [15, 20]
expect(snapPoint(point2, 5, 'floor')).toBe(true)
expect(point2).toEqual([15, 20])
const point3: Point = [15.1, -18.7]
expect(snapPoint(point3, 10, 'floor')).toBe(true)
expect(point3).toEqual([10, -20])
})
test('createBounds correctly creates bounding box', ({ expect }) => {
const objects = [
{ boundingRect: [0, 0, 10, 10] as Rect },

View File

@@ -351,11 +351,15 @@ export function createBounds(
* @returns `true` if snapTo is truthy, otherwise `false`
* @remarks `NaN` propagates through this function and does not affect return value.
*/
export function snapPoint(pos: Point | Rect, snapTo: number): boolean {
export function snapPoint(
pos: Point | Rect,
snapTo: number,
method: 'round' | 'ceil' | 'floor' = 'round'
): boolean {
if (!snapTo) return false
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round(pos[1] / snapTo)
pos[0] = snapTo * Math[method](pos[0] / snapTo)
pos[1] = snapTo * Math[method](pos[1] / snapTo)
return true
}

View File

@@ -12,6 +12,9 @@ export function parseSlotTypes(type: ISlotType): string[] {
* @param name The name to make unique
* @param existingNames The names that already exist. Default: an empty array
* @returns The name, or a unique name if it already exists.
* @remark Used by SubgraphInputNode to deduplicate input names when promoting
* the same widget name from multiple node instances (e.g. `seed` → `seed_1`).
* Extensions matching by slot name should account for the `_N` suffix.
*/
export function nextUniqueName(
name: string,

View File

@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
import { usePromotionStore } from '@/stores/promotionStore'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -456,4 +457,50 @@ describe('SubgraphIO - Empty Slot Connection', () => {
expect(link.origin_slot).toBe(1) // Should be the second slot
}
)
subgraphTest(
'creates distinct named inputs when promoting same widget name from multiple node instances',
({ subgraphWithNode }) => {
const { subgraph, subgraphNode } = subgraphWithNode
const firstNode = new LGraphNode('First Seed Node')
const firstInput = firstNode.addInput('seed', 'number')
firstNode.addWidget('number', 'seed', 1, () => undefined)
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('Second Seed Node')
const secondInput = secondNode.addInput('seed', 'number')
secondNode.addWidget('number', 'seed', 2, () => undefined)
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.connectByType(-1, firstNode, 'number')
subgraph.inputNode.connectByType(-1, secondNode, 'number')
expect(subgraph.inputs.map((input) => input.name)).toStrictEqual([
'input',
'seed',
'seed_1'
])
expect(subgraphNode.inputs.map((input) => input.name)).toStrictEqual([
'input',
'seed',
'seed_1'
])
expect(subgraphNode.widgets.map((widget) => widget.name)).toStrictEqual([
'seed',
'seed_1'
])
expect(
usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([
{ interiorNodeId: String(firstNode.id), widgetName: 'seed' },
{ interiorNodeId: String(secondNode.id), widgetName: 'seed' }
])
}
)
})

View File

@@ -15,6 +15,7 @@ import type { NodeLike } from '@/lib/litegraph/src/types/NodeLike'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { findFreeSlotOfType } from '@/lib/litegraph/src/utils/collections'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import { EmptySubgraphInput } from './EmptySubgraphInput'
import { SubgraphIONodeBase } from './SubgraphIONodeBase'
@@ -130,8 +131,10 @@ export class SubgraphInputNode
if (slot === -1) {
// This indicates a connection is being made from the "Empty" slot.
// We need to create a new, concrete input on the subgraph that matches the target.
const existingNames = this.subgraph.inputs.map((input) => input.name)
const uniqueName = nextUniqueName(inputSlot.slot.name, existingNames)
const newSubgraphInput = this.subgraph.addInput(
inputSlot.slot.name,
uniqueName,
String(inputSlot.slot.type ?? '')
)
const newSlotIndex = this.slots.indexOf(newSubgraphInput)

View File

@@ -6,6 +6,8 @@
* IO synchronization, and edge cases.
*/
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
@@ -615,3 +617,35 @@ describe.skip('SubgraphNode Cleanup', () => {
expect(abortSpy2).toHaveBeenCalledTimes(1)
})
})
describe('SubgraphNode promotion view keys', () => {
it('distinguishes tuples that differ only by colon placement', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const nodeWithKeyBuilder = subgraphNode as unknown as {
_makePromotionViewKey: (
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName?: string
) => string
}
const firstKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a:b',
'c'
)
const secondKey = nodeWithKeyBuilder._makePromotionViewKey(
'65',
'18',
'a',
'b:c'
)
expect(firstKey).not.toBe(secondKey)
})
})

View File

@@ -35,7 +35,9 @@ import {
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -52,6 +54,12 @@ workflowSvg.src =
type LinkedPromotionEntry = {
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
}
type PromotionEntry = {
interiorNodeId: string
widgetName: string
}
@@ -91,46 +99,113 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
* `onAdded()`, so construction-time promotions require normal add-to-graph
* lifecycle to persist.
*/
private _pendingPromotions: Array<{
interiorNodeId: string
widgetName: string
}> = []
private _pendingPromotions: PromotionEntry[] = []
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
hasMissingBoundSourceWidget: boolean
entries: LinkedPromotionEntry[]
}
private _promotedViewsCache?: {
version: number
entriesRef: PromotionEntry[]
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
// Declared as accessor via Object.defineProperty in constructor.
// TypeScript doesn't allow overriding a property with get/set syntax,
// so we use declare + defineProperty instead.
declare widgets: IBaseWidget[]
private _resolveLinkedPromotionByInputName(
inputName: string
private _resolveLinkedPromotionBySubgraphInput(
subgraphInput: SubgraphInput
): { interiorNodeId: string; widgetName: string } | undefined {
const resolvedTarget = resolveSubgraphInputTarget(this, inputName)
if (!resolvedTarget) return undefined
// Preserve deterministic representative selection for multi-linked inputs:
// the first connected source remains the promoted linked view.
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) continue
return {
interiorNodeId: resolvedTarget.nodeId,
widgetName: resolvedTarget.widgetName
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find(
(entry) => entry.link === linkId
)
if (!targetInput) continue
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
if (inputNode.isSubgraphNode())
return {
interiorNodeId: String(inputNode.id),
widgetName: targetInput.name
}
return {
interiorNodeId: String(inputNode.id),
widgetName: targetWidget.name
}
}
}
private _getLinkedPromotionEntries(): LinkedPromotionEntry[] {
private _getLinkedPromotionEntries(cache = true): LinkedPromotionEntry[] {
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
const cached = this._linkedEntriesCache
if (
cache &&
cached?.version === this._cacheVersion &&
cached.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
)
return cached.entries
const linkedEntries: LinkedPromotionEntry[] = []
// TODO(pr9282): Optimization target. This path runs on widgets getter reads
// and resolves each input link chain eagerly.
for (const input of this.inputs) {
const resolved = this._resolveLinkedPromotionByInputName(input.name)
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
if (boundWidget) {
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
const hasBoundSourceWidget =
boundNode?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) === true
if (hasBoundSourceWidget) {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
interiorNodeId: boundWidget.sourceNodeId,
widgetName: boundWidget.sourceWidgetName
})
continue
}
}
const resolved =
this._resolveLinkedPromotionBySubgraphInput(subgraphInput)
if (!resolved) continue
linkedEntries.push({ inputName: input.name, ...resolved })
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
...resolved
})
}
const seenEntryKeys = new Set<string>()
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
entry.inputName,
entry.inputKey,
entry.interiorNodeId,
entry.widgetName
entry.widgetName,
entry.inputName
)
if (seenEntryKeys.has(entryKey)) return false
@@ -138,24 +213,73 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return true
})
if (cache)
this._linkedEntriesCache = {
version: this._cacheVersion,
hasMissingBoundSourceWidget,
entries: deduplicatedEntries
}
return deduplicatedEntries
}
private _hasMissingBoundSourceWidget(): boolean {
return this.inputs.some((input) => {
const boundWidget =
input._widget && isPromotedWidgetView(input._widget)
? input._widget
: undefined
if (!boundWidget) return false
const boundNode = this.subgraph.getNodeById(boundWidget.sourceNodeId)
return (
boundNode?.widgets?.some(
(widget) => widget.name === boundWidget.sourceWidgetName
) !== true
)
})
}
private _getPromotedViews(): PromotedWidgetView[] {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const hasMissingBoundSourceWidget = this._hasMissingBoundSourceWidget()
const cachedViews = this._promotedViewsCache
if (
cachedViews?.version === this._cacheVersion &&
cachedViews.entriesRef === entries &&
cachedViews.hasMissingBoundSourceWidget === hasMissingBoundSourceWidget
)
return cachedViews.views
const linkedEntries = this._getLinkedPromotionEntries()
const { displayNameByViewKey, reconcileEntries } =
this._buildPromotionReconcileState(entries, linkedEntries)
return this._promotedViewManager.reconcile(reconcileEntries, (entry) =>
createPromotedWidgetView(
this,
entry.interiorNodeId,
entry.widgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
)
const views = this._promotedViewManager.reconcile(
reconcileEntries,
(entry) =>
createPromotedWidgetView(
this,
entry.interiorNodeId,
entry.widgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
)
)
this._promotedViewsCache = {
version: this._cacheVersion,
entriesRef: entries,
hasMissingBoundSourceWidget,
views
}
return views
}
private _invalidatePromotedViewsCache(): void {
this._cacheVersion++
}
private _syncPromotions(): void {
@@ -163,10 +287,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const store = usePromotionStore()
const entries = store.getPromotionsRef(this.rootGraph.id, this.id)
const linkedEntries = this._getLinkedPromotionEntries()
const { mergedEntries, shouldPersistLinkedOnly } =
this._buildPromotionPersistenceState(entries, linkedEntries)
if (!shouldPersistLinkedOnly) return
const linkedEntries = this._getLinkedPromotionEntries(false)
// Intentionally preserve independent store promotions when linked coverage is partial;
// tests assert that mixed linked/independent states must not collapse to linked-only.
const { mergedEntries } = this._buildPromotionPersistenceState(
entries,
linkedEntries
)
const hasChanged =
mergedEntries.length !== entries.length ||
@@ -181,7 +308,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _buildPromotionReconcileState(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: PromotionEntry[],
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
@@ -197,48 +324,64 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
const linkedReconcileEntries =
this._buildLinkedReconcileEntries(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
const reconcileEntries = shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackStoredEntries]
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
reconcileEntries: shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackStoredEntries]
reconcileEntries
}
}
private _buildPromotionPersistenceState(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: PromotionEntry[],
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: Array<{ interiorNodeId: string; widgetName: string }>
shouldPersistLinkedOnly: boolean
mergedEntries: PromotionEntry[]
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(linkedEntries)
const shouldPersistLinkedOnly = this._shouldPersistLinkedOnly(
linkedEntries,
fallbackStoredEntries
)
return {
mergedEntries: shouldPersistLinkedOnly
? linkedPromotionEntries
: [...linkedPromotionEntries, ...fallbackStoredEntries],
shouldPersistLinkedOnly
: [...linkedPromotionEntries, ...fallbackStoredEntries]
}
}
private _collectLinkedAndFallbackEntries(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: PromotionEntry[],
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: Array<{
interiorNodeId: string
widgetName: string
}>
fallbackStoredEntries: Array<{ interiorNodeId: string; widgetName: string }>
linkedPromotionEntries: PromotionEntry[]
fallbackStoredEntries: PromotionEntry[]
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const fallbackStoredEntries = this._getFallbackStoredEntries(
const excludedEntryKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
for (const key of connectedEntryKeys) {
excludedEntryKeys.add(key)
}
const prePruneFallbackStoredEntries = this._getFallbackStoredEntries(
entries,
excludedEntryKeys
)
const fallbackStoredEntries = this._pruneStaleAliasFallbackEntries(
prePruneFallbackStoredEntries,
linkedPromotionEntries
)
@@ -249,14 +392,37 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[]
linkedEntries: LinkedPromotionEntry[],
fallbackStoredEntries: PromotionEntry[]
): boolean {
return this.inputs.length > 0 && linkedEntries.length === this.inputs.length
if (
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
)
return false
const linkedWidgetNames = new Set(
linkedEntries.map((entry) => entry.widgetName)
)
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
const sourceNode = this.subgraph.getNodeById(entry.interiorNodeId)
const hasSourceWidget =
sourceNode?.widgets?.some(
(widget) => widget.name === entry.widgetName
) === true
if (hasSourceWidget) return true
// If the fallback widget name overlaps a linked widget name, keep it
// until aliasing can be positively proven.
return linkedWidgetNames.has(entry.widgetName)
})
return !hasFallbackToKeep
}
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string }> {
): PromotionEntry[] {
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName
@@ -264,33 +430,98 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _getFallbackStoredEntries(
entries: Array<{ interiorNodeId: string; widgetName: string }>,
linkedPromotionEntries: Array<{
interiorNodeId: string
widgetName: string
}>
): Array<{ interiorNodeId: string; widgetName: string }> {
const linkedKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
entries: PromotionEntry[],
excludedEntryKeys: Set<string>
): PromotionEntry[] {
return entries.filter(
(entry) =>
!linkedKeys.has(
!excludedEntryKeys.has(
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
)
)
}
private _pruneStaleAliasFallbackEntries(
fallbackStoredEntries: PromotionEntry[],
linkedPromotionEntries: PromotionEntry[]
): PromotionEntry[] {
if (
fallbackStoredEntries.length === 0 ||
linkedPromotionEntries.length === 0
)
return fallbackStoredEntries
const linkedConcreteKeys = new Set(
linkedPromotionEntries
.map((entry) => this._resolveConcretePromotionEntryKey(entry))
.filter((key): key is string => key !== undefined)
)
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
const prunedEntries: PromotionEntry[] = []
for (const entry of fallbackStoredEntries) {
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
if (concreteKey && linkedConcreteKeys.has(concreteKey)) continue
prunedEntries.push(entry)
}
return prunedEntries
}
private _resolveConcretePromotionEntryKey(
entry: PromotionEntry
): string | undefined {
const result = resolveConcretePromotedWidget(
this,
entry.interiorNodeId,
entry.widgetName
)
if (result.status !== 'resolved') return undefined
return this._makePromotionEntryKey(
String(result.resolved.node.id),
result.resolved.widget.name
)
}
private _getConnectedPromotionEntryKeys(): Set<string> {
const connectedEntryKeys = new Set<string>()
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()
for (const widget of connectedWidgets) {
if (!hasWidgetNode(widget)) continue
connectedEntryKeys.add(
this._makePromotionEntryKey(String(widget.node.id), widget.name)
)
}
}
return connectedEntryKeys
}
private _buildLinkedReconcileEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
return linkedEntries.map(({ inputName, interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName,
viewKey: this._makePromotionViewKey(inputName, interiorNodeId, widgetName)
}))
return linkedEntries.map(
({ inputKey, inputName, interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName,
viewKey: this._makePromotionViewKey(
inputKey,
interiorNodeId,
widgetName,
inputName
)
})
)
}
private _buildDisplayNameByViewKey(
@@ -299,9 +530,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return new Map(
linkedEntries.map((entry) => [
this._makePromotionViewKey(
entry.inputName,
entry.inputKey,
entry.interiorNodeId,
entry.widgetName
entry.widgetName,
entry.inputName
),
entry.inputName
])
@@ -316,11 +548,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _makePromotionViewKey(
inputName: string,
inputKey: string,
interiorNodeId: string,
widgetName: string
widgetName: string,
inputName = ''
): string {
return `${inputName}:${interiorNodeId}:${widgetName}`
return JSON.stringify([inputKey, interiorNodeId, widgetName, inputName])
}
private _resolveLegacyEntry(
@@ -378,22 +611,34 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(e) => {
const subgraphInput = e.detail.input
const { name, type } = subgraphInput
const existingInput = this.inputs.find((i) => i.name === name)
const existingInput = this.inputs.find(
(input) => input._subgraphSlot === subgraphInput
)
if (existingInput) {
const linkId = subgraphInput.linkIds[0]
const { inputNode, input } = subgraph.links[linkId].resolve(subgraph)
const widget = inputNode?.widgets?.find?.((w) => w.name === name)
if (widget && inputNode)
if (linkId === undefined) return
const link = this.subgraph.getLink(linkId)
if (!link) return
const { inputNode, input } = link.resolve(subgraph)
if (!inputNode || !input) return
const widget = inputNode.getWidgetFromSlot(input)
if (widget)
this._setWidget(
subgraphInput,
existingInput,
widget,
input?.widget,
input.widget,
inputNode
)
return
}
const input = this.addInput(name, type)
const input = this.addInput(name, type, {
_subgraphSlot: subgraphInput
})
this._invalidatePromotedViewsCache()
this._addSubgraphInputListeners(subgraphInput, input)
},
@@ -407,6 +652,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (widget) this.ensureWidgetRemoved(widget)
this.removeInput(e.detail.index)
this._invalidatePromotedViewsCache()
this._syncPromotions()
this.setDirtyCanvas(true, true)
},
@@ -442,6 +688,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (input._widget) {
input._widget.label = newName
}
this._invalidatePromotedViewsCache()
this.graph?.trigger('node:slot-label:changed', {
nodeId: this.id,
slotType: NodeSlotType.INPUT
@@ -493,6 +740,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput: SubgraphInput,
input: INodeInputSlot & Partial<ISubgraphInput>
) {
input._subgraphSlot = subgraphInput
if (
input._listenerController &&
typeof input._listenerController.abort === 'function'
@@ -505,36 +754,39 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-connected',
(e) => {
const widget = subgraphInput._widget
if (!widget) return
this._invalidatePromotedViewsCache()
// If this widget is already promoted, demote it first
// so it transitions cleanly to being linked via SubgraphInput.
// `SubgraphInput.connect()` dispatches before appending to `linkIds`,
// so resolve by current links would miss this new connection.
// Keep the earliest bound view once present, and only bind from event
// payload when this input has no representative yet.
const nodeId = String(e.detail.node.id)
if (
usePromotionStore().isPromoted(
this.rootGraph.id,
this.id,
nodeId,
widget.name
e.detail.widget.name
)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
nodeId,
widget.name
e.detail.widget.name
)
}
const widgetLocator = e.detail.input.widget
this._setWidget(
subgraphInput,
input,
widget,
widgetLocator,
e.detail.node
)
const didSetWidgetFromEvent = !input._widget
if (didSetWidgetFromEvent)
this._setWidget(
subgraphInput,
input,
e.detail.widget,
e.detail.input.widget,
e.detail.node
)
this._syncPromotions()
},
{ signal }
@@ -543,9 +795,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
subgraphInput.events.addEventListener(
'input-disconnected',
() => {
// If the input is connected to more than one widget, don't remove the widget
this._invalidatePromotedViewsCache()
// If links remain, rebind to the current representative.
const connectedWidgets = subgraphInput.getConnectedWidgets()
if (connectedWidgets.length > 0) return
if (connectedWidgets.length > 0) {
this._resolveInputWidget(subgraphInput, input)
this._syncPromotions()
return
}
if (input._widget) this.ensureWidgetRemoved(input._widget)
@@ -558,6 +816,62 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
}
private _rebindInputSubgraphSlots(): void {
this._invalidatePromotedViewsCache()
const subgraphSlots = [...this.subgraph.inputNode.slots]
const slotsBySignature = new Map<string, SubgraphInput[]>()
const slotsByName = new Map<string, SubgraphInput[]>()
for (const slot of subgraphSlots) {
const signature = `${slot.name}:${String(slot.type)}`
const signatureSlots = slotsBySignature.get(signature)
if (signatureSlots) {
signatureSlots.push(slot)
} else {
slotsBySignature.set(signature, [slot])
}
const nameSlots = slotsByName.get(slot.name)
if (nameSlots) {
nameSlots.push(slot)
} else {
slotsByName.set(slot.name, [slot])
}
}
const assignedSlotIds = new Set<string>()
const takeUnassignedSlot = (
slots: SubgraphInput[] | undefined
): SubgraphInput | undefined => {
if (!slots) return undefined
return slots.find((slot) => !assignedSlotIds.has(String(slot.id)))
}
for (const input of this.inputs) {
const existingSlot = input._subgraphSlot
if (
existingSlot &&
this.subgraph.inputNode.slots.some((slot) => slot === existingSlot)
) {
assignedSlotIds.add(String(existingSlot.id))
continue
}
const signature = `${input.name}:${String(input.type)}`
const matchedSlot =
takeUnassignedSlot(slotsBySignature.get(signature)) ??
takeUnassignedSlot(slotsByName.get(input.name))
if (matchedSlot) {
input._subgraphSlot = matchedSlot
assignedSlotIds.add(String(matchedSlot.id))
} else {
delete input._subgraphSlot
}
}
}
override configure(info: ExportedSubgraphInstance): void {
for (const input of this.inputs) {
if (
@@ -570,8 +884,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.inputs.length = 0
this.inputs.push(
...this.subgraph.inputNode.slots.map(
(slot) =>
...this.subgraph.inputNode.slots.map((slot) =>
Object.assign(
new NodeInputSlot(
{
name: slot.name,
@@ -581,7 +895,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
link: null
},
this
)
),
{
_subgraphSlot: slot
}
)
)
)
@@ -606,6 +924,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
override _internalConfigureAfterSlots() {
this._rebindInputSubgraphSlots()
// Ensure proxyWidgets is initialized so it serializes
this.properties.proxyWidgets ??= []
@@ -613,10 +933,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Do NOT clear properties.proxyWidgets — it was already populated
// from serialized data by super.configure(info) before this runs.
this._promotedViewManager.clear()
this._invalidatePromotedViewsCache()
// Hydrate the store from serialized properties.proxyWidgets
const raw = parseProxyWidgets(this.properties.proxyWidgets)
const store = usePromotionStore()
const entries = raw
.map(([nodeId, widgetName]) => {
if (nodeId === '-1') {
@@ -633,6 +955,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return { interiorNodeId: nodeId, widgetName }
})
.filter((e): e is NonNullable<typeof e> => e !== null)
store.setPromotions(this.rootGraph.id, this.id, entries)
// Write back resolved entries so legacy -1 format doesn't persist
@@ -645,9 +968,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Check all inputs for connected widgets
for (const input of this.inputs) {
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
const subgraphInput = input._subgraphSlot
if (!subgraphInput) {
// Skip inputs that don't exist in the subgraph definition
// This can happen when loading workflows with dynamically added inputs
@@ -711,6 +1032,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
inputWidget: IWidgetLocator | undefined,
interiorNode: LGraphNode
) {
this._invalidatePromotedViewsCache()
this._flushPendingPromotions()
const nodeId = String(interiorNode.id)
@@ -760,8 +1082,18 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
nodeId,
widgetName,
() =>
createPromotedWidgetView(this, nodeId, widgetName, subgraphInput.name),
this._makePromotionViewKey(subgraphInput.name, nodeId, widgetName)
createPromotedWidgetView(
this,
nodeId,
widgetName,
input.label ?? subgraphInput.name
),
this._makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
input.label ?? input.name
)
)
// NOTE: This code creates linked chains of prototypes for passing across
@@ -817,6 +1149,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return super.addInput(name, type, inputProperties)
}
override getSlotFromWidget(
widget: IBaseWidget | undefined
): INodeInputSlot | undefined {
if (!widget || !isPromotedWidgetView(widget))
return super.getSlotFromWidget(widget)
return this.inputs.find((input) => input._widget === widget)
}
override getWidgetFromSlot(slot: INodeInputSlot): IBaseWidget | undefined {
if (slot._widget) return slot._widget
return super.getWidgetFromSlot(slot)
}
override getInputLink(slot: number): LLink | null {
// Output side: the link from inside the subgraph
const innerLink = this.subgraph.outputNode.slots[slot].getLinks().at(0)
@@ -946,18 +1292,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _removePromotedView(view: PromotedWidgetView): void {
this._invalidatePromotedViewsCache()
this._promotedViewManager.remove(view.sourceNodeId, view.sourceWidgetName)
// Reconciled views can also be keyed by inputName-scoped view keys.
// Remove both key shapes to avoid stale cache entries across promote/rebind flows.
this._promotedViewManager.removeByViewKey(
view.sourceNodeId,
view.sourceWidgetName,
this._makePromotionViewKey(
view.name,
for (const input of this.inputs) {
if (input._widget !== view || !input._subgraphSlot) continue
const inputName = input.label ?? input.name
this._promotedViewManager.removeByViewKey(
view.sourceNodeId,
view.sourceWidgetName
view.sourceWidgetName,
this._makePromotionViewKey(
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,
inputName
)
)
)
}
}
override removeWidget(widget: IBaseWidget): void {
@@ -996,6 +1348,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
override onRemoved(): void {
this._eventAbortController.abort()
this._invalidatePromotedViewsCache()
for (const widget of this.widgets) {
if (isPromotedWidgetView(widget)) {
@@ -1062,9 +1415,9 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
for (const input of this.inputs) {
if (!input._widget) continue
const subgraphInput = this.subgraph.inputNode.slots.find(
(slot) => slot.name === input.name
)
const subgraphInput =
input._subgraphSlot ??
this.subgraph.inputNode.slots.find((slot) => slot.name === input.name)
if (!subgraphInput) continue
const connectedWidgets = subgraphInput.getConnectedWidgets()

View File

@@ -0,0 +1,322 @@
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
export const subgraphComplexPromotion1 = {
id: 'e49902fa-ee3e-40e6-a59e-c8931888ad0e',
revision: 0,
last_node_id: 21,
last_link_id: 23,
nodes: [
{
id: 12,
type: 'PreviewAny',
pos: [1367.8236034435063, 305.51100163315823],
size: [225, 166],
flags: {},
order: 3,
mode: 0,
inputs: [
{
name: 'source',
type: '*',
link: 21
}
],
outputs: [],
properties: {
'Node name for S&R': 'PreviewAny'
},
widgets_values: [null, null, null]
},
{
id: 13,
type: 'PreviewAny',
pos: [1271.9742739655217, 551.9124470179938],
size: [225, 166],
flags: {},
order: 1,
mode: 0,
inputs: [
{
name: 'source',
type: '*',
link: 19
}
],
outputs: [],
properties: {
'Node name for S&R': 'PreviewAny'
},
widgets_values: [null, null, null]
},
{
id: 14,
type: 'PreviewAny',
pos: [1414.8695925586444, 847.9456885036253],
size: [225, 166],
flags: {},
order: 2,
mode: 0,
inputs: [
{
name: 'source',
type: '*',
link: 20
}
],
outputs: [],
properties: {
'Node name for S&R': 'PreviewAny'
},
widgets_values: [null, null, null]
},
{
id: 21,
type: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f',
pos: [741.0375276545419, 560.8496560588814],
size: [225, 305.3333435058594],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [
{
name: 'STRING',
type: 'STRING',
links: [19]
},
{
name: 'STRING_1',
type: 'STRING',
links: [20]
},
{
name: 'STRING_2',
type: 'STRING',
links: [21]
}
],
properties: {
proxyWidgets: [
['20', 'string_a'],
['19', 'string_a'],
['18', 'string_a']
]
},
widgets_values: []
}
],
links: [
[19, 21, 0, 13, 0, 'STRING'],
[20, 21, 1, 14, 0, 'STRING'],
[21, 21, 2, 12, 0, 'STRING']
],
groups: [],
definitions: {
subgraphs: [
{
id: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f',
version: 1,
state: {
lastGroupId: 0,
lastNodeId: 21,
lastLinkId: 23,
lastRerouteId: 0
},
revision: 0,
config: {},
name: 'New Subgraph',
inputNode: {
id: -10,
bounding: [596.9206067268835, 805.5404332481304, 120, 60]
},
outputNode: {
id: -20,
bounding: [1376.7286067268833, 769.5404332481304, 120, 100]
},
inputs: [
{
id: '78479bf4-8145-41d5-9d11-c38e3149fc59',
name: 'string_a',
type: 'STRING',
linkIds: [22, 23],
pos: [696.9206067268835, 825.5404332481304]
}
],
outputs: [
{
id: 'aa263e4e-b558-4dbf-bcb9-ff0c1c72cbef',
name: 'STRING',
type: 'STRING',
linkIds: [16],
localized_name: 'STRING',
pos: [1396.7286067268833, 789.5404332481304]
},
{
id: '8eee6fe3-dc2f-491a-9e01-04ef83309dad',
name: 'STRING_1',
type: 'STRING',
linkIds: [17],
localized_name: 'STRING_1',
pos: [1396.7286067268833, 809.5404332481304]
},
{
id: 'a446d5b9-6042-434d-848a-5d3af5e8e0d4',
name: 'STRING_2',
type: 'STRING',
linkIds: [18],
localized_name: 'STRING_2',
pos: [1396.7286067268833, 829.5404332481304]
}
],
widgets: [],
nodes: [
{
id: 18,
type: 'StringConcatenate',
pos: [818.5102631756379, 706.4562049408103],
size: [480, 268],
flags: {},
order: 0,
mode: 0,
inputs: [
{
localized_name: 'string_a',
name: 'string_a',
type: 'STRING',
widget: {
name: 'string_a'
},
link: 23
}
],
outputs: [
{
localized_name: 'STRING',
name: 'STRING',
type: 'STRING',
links: [16]
}
],
title: 'InnerCatB',
properties: {
'Node name for S&R': 'StringConcatenate'
},
widgets_values: ['Poop', '_B', '']
},
{
id: 19,
type: 'StringConcatenate',
pos: [812.9370280206649, 1040.648423402667],
size: [480, 268],
flags: {},
order: 1,
mode: 0,
inputs: [],
outputs: [
{
localized_name: 'STRING',
name: 'STRING',
type: 'STRING',
links: [17]
}
],
title: 'InnerCatC',
properties: {
'Node name for S&R': 'StringConcatenate'
},
widgets_values: ['', '_C', '']
},
{
id: 20,
type: 'StringConcatenate',
pos: [824.7110975088726, 386.4230523609899],
size: [480, 268],
flags: {},
order: 2,
mode: 0,
inputs: [
{
localized_name: 'string_a',
name: 'string_a',
type: 'STRING',
widget: {
name: 'string_a'
},
link: 22
}
],
outputs: [
{
localized_name: 'STRING',
name: 'STRING',
type: 'STRING',
links: [18]
}
],
title: 'InnerCatA',
properties: {
'Node name for S&R': 'StringConcatenate'
},
widgets_values: ['Poop', '_A', '']
}
],
groups: [],
links: [
{
id: 16,
origin_id: 18,
origin_slot: 0,
target_id: -20,
target_slot: 0,
type: 'STRING'
},
{
id: 17,
origin_id: 19,
origin_slot: 0,
target_id: -20,
target_slot: 1,
type: 'STRING'
},
{
id: 18,
origin_id: 20,
origin_slot: 0,
target_id: -20,
target_slot: 2,
type: 'STRING'
},
{
id: 22,
origin_id: -10,
origin_slot: 0,
target_id: 20,
target_slot: 0,
type: 'STRING'
},
{
id: 23,
origin_id: -10,
origin_slot: 0,
target_id: 18,
target_slot: 0,
type: 'STRING'
}
],
extra: {
workflowRendererVersion: 'Vue'
}
}
]
},
config: {},
extra: {
ds: {
scale: 0.6638894832438259,
offset: [-408.2009703049473, -183.8039508449224]
},
workflowRendererVersion: 'Vue',
frontendVersion: '1.42.3'
},
version: 0.4
} as const as unknown as ISerialisedGraph

View File

@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
cleanupComplexPromotionFixtureNodeType,
setupComplexPromotionFixture
} from './subgraphHelpers'
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
describe('setupComplexPromotionFixture', () => {
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
it('can clean up the globally registered fixture node type', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
setupComplexPromotionFixture()
expect(
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
).toBeDefined()
cleanupComplexPromotionFixtureNodeType()
expect(
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
).toBeUndefined()
})
})

View File

@@ -8,7 +8,12 @@
import { expect } from 'vitest'
import type { ISlotType, NodeId } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
ExportedSubgraph,
@@ -17,6 +22,27 @@ import type {
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
import { subgraphComplexPromotion1 } from './subgraphComplexPromotion1'
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
class FixtureStringConcatenateNode extends LGraphNode {
constructor() {
super('StringConcatenate')
const input = this.addInput('string_a', 'STRING')
input.widget = { name: 'string_a' }
this.addOutput('STRING', 'STRING')
this.addWidget('text', 'string_a', '', () => {})
this.addWidget('text', 'string_b', '', () => {})
this.addWidget('text', 'delimiter', '', () => {})
}
}
export function cleanupComplexPromotionFixtureNodeType(): void {
if (!LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]) return
LiteGraph.unregisterNodeType(FIXTURE_STRING_CONCAT_TYPE)
}
interface TestSubgraphOptions {
id?: UUID
name?: string
@@ -209,6 +235,48 @@ export function createTestSubgraphNode(
return new SubgraphNode(parentGraph, subgraph, instanceData)
}
export function setupComplexPromotionFixture(): {
graph: LGraph
subgraph: Subgraph
hostNode: SubgraphNode
} {
const fixture = structuredClone(subgraphComplexPromotion1)
const subgraphData = fixture.definitions?.subgraphs?.[0]
if (!subgraphData)
throw new Error('Expected fixture to contain one subgraph definition')
cleanupComplexPromotionFixtureNodeType()
LiteGraph.registerNodeType(
FIXTURE_STRING_CONCAT_TYPE,
FixtureStringConcatenateNode
)
for (const node of subgraphData.nodes as Array<{ type: string }>) {
if (node.type === 'StringConcatenate')
node.type = FIXTURE_STRING_CONCAT_TYPE
}
const hostNodeData = fixture.nodes.find((node) => node.id === 21)
if (!hostNodeData)
throw new Error('Expected fixture to contain subgraph instance node id 21')
const graph = new LGraph()
const subgraph = graph.createSubgraph(subgraphData as ExportedSubgraph)
subgraph.configure(subgraphData as ExportedSubgraph)
const hostNode = new SubgraphNode(
graph,
subgraph,
hostNodeData as ExportedSubgraphInstance
)
graph.add(hostNode)
return {
graph,
subgraph,
hostNode
}
}
/**
* Creates a nested hierarchy of subgraphs for testing deep nesting scenarios.
* @param options Configuration for the nested structure

View File

@@ -326,6 +326,8 @@
"nightly": "NIGHTLY",
"profile": "Profile",
"noItems": "No items",
"preloadError": "A required resource failed to load. Please reload the page.",
"preloadErrorTitle": "Loading Error",
"recents": "Recents",
"partner": "Partner",
"collapseAll": "Collapse all",

View File

@@ -217,22 +217,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
commandId: 'Comfy.Canvas.SelectAll',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'c'
},
commandId: 'Comfy.Canvas.CopySelected',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,
key: 'v'
},
commandId: 'Comfy.Canvas.PasteFromClipboard',
targetElementId: 'graph-canvas-container'
},
{
combo: {
ctrl: true,

View File

@@ -141,7 +141,7 @@ describe('keybindingService - Canvas Keybindings', () => {
)
})
it('should execute CopySelected for Ctrl+C on canvas', async () => {
it('should not intercept Ctrl+C to allow native copy event', async () => {
const event = createTestKeyboardEvent('c', {
ctrlKey: true,
target: canvasChild
@@ -149,12 +149,10 @@ describe('keybindingService - Canvas Keybindings', () => {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.CopySelected'
)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute PasteFromClipboard for Ctrl+V on canvas', async () => {
it('should not intercept Ctrl+V to allow native paste event', async () => {
const event = createTestKeyboardEvent('v', {
ctrlKey: true,
target: canvasChild
@@ -162,9 +160,7 @@ describe('keybindingService - Canvas Keybindings', () => {
await keybindingService.keybindHandler(event)
expect(vi.mocked(useCommandStore().execute)).toHaveBeenCalledWith(
'Comfy.Canvas.PasteFromClipboard'
)
expect(vi.mocked(useCommandStore().execute)).not.toHaveBeenCalled()
})
it('should execute PasteFromClipboardWithConnect for Ctrl+Shift+V on canvas', async () => {

View File

@@ -1,6 +1,7 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
checkForCompletedTopup as checkTopupUtil,
@@ -278,6 +279,7 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
@@ -290,7 +292,9 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source

View File

@@ -1,6 +1,7 @@
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
@@ -277,6 +278,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
trigger_source?: ExecutionTriggerSource
}): void {
const executionContext = getExecutionContext()
const { mode, isAppMode } = useAppMode()
const runButtonProperties: RunButtonProperties = {
subscribe_to_run: options?.subscribe_to_run || false,
@@ -289,7 +291,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
api_node_names: executionContext.api_node_names,
has_toolkit_nodes: executionContext.has_toolkit_nodes,
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
}
this.lastTriggerSource = options?.trigger_source

View File

@@ -63,6 +63,8 @@ export interface RunButtonProperties {
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
view_mode?: string
is_app_mode?: boolean
}
/**

View File

@@ -78,6 +78,7 @@ vi.mock('vue-i18n', () => ({
const mockShowLayoutDialog = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockHideTemplateSelector = vi.hoisted(() => vi.fn())
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
@@ -91,6 +92,12 @@ vi.mock('@/stores/dialogStore', () => ({
})
}))
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
useWorkflowTemplateSelectorDialog: () => ({
hide: mockHideTemplateSelector
})
}))
function makePayload(
overrides: Partial<SharedWorkflowPayload> = {}
): SharedWorkflowPayload {
@@ -173,6 +180,18 @@ describe('useSharedWorkflowUrlLoader', () => {
)
})
it('hides template selector when user confirms opening shared workflow', async () => {
mockQueryParams = { share: 'share-id-1' }
mockShowLayoutDialog.mockImplementation(() => {
resolveDialogWithConfirm(makePayload())
})
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
await loadSharedWorkflowFromUrl()
expect(mockHideTemplateSelector).toHaveBeenCalledTimes(1)
})
it('does not load graph when user cancels dialog', async () => {
mockQueryParams = { share: 'share-id-1' }
mockShowLayoutDialog.mockImplementation(() => {
@@ -190,6 +209,18 @@ describe('useSharedWorkflowUrlLoader', () => {
)
})
it('does not hide template selector when user cancels shared workflow dialog', async () => {
mockQueryParams = { share: 'share-id-1' }
mockShowLayoutDialog.mockImplementation(() => {
resolveDialogWithCancel()
})
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
await loadSharedWorkflowFromUrl()
expect(mockHideTemplateSelector).not.toHaveBeenCalled()
})
it('calls import when non-owned assets exist and user confirms', async () => {
mockQueryParams = { share: 'share-id-1' }
const payload = makePayload({
@@ -241,6 +272,18 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(mockImportPublishedAssets).not.toHaveBeenCalled()
})
it('hides template selector when user chooses open-only', async () => {
mockQueryParams = { share: 'share-id-1' }
mockShowLayoutDialog.mockImplementation(() => {
resolveDialogWithOpenOnly(makePayload())
})
const { loadSharedWorkflowFromUrl } = useSharedWorkflowUrlLoader()
await loadSharedWorkflowFromUrl()
expect(mockHideTemplateSelector).toHaveBeenCalledTimes(1)
})
it('shows toast on import failure and returns loaded-without-assets', async () => {
mockQueryParams = { share: 'share-id-1' }
const payload = makePayload({

View File

@@ -2,6 +2,7 @@ import { useToast } from 'primevue/usetoast'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
import type { SharedWorkflowPayload } from '@/platform/workflow/sharing/types/shareTypes'
import {
@@ -35,6 +36,7 @@ export function useSharedWorkflowUrlLoader() {
const workflowShareService = useWorkflowShareService()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
function isValidParameter(param: string): boolean {
@@ -133,6 +135,8 @@ export function useSharedWorkflowUrlLoader() {
return 'cancelled'
}
templateSelectorDialog.hide()
const { payload } = result
const workflowName = payload.name || t('openSharedWorkflow.dialogTitle')
const nonOwnedAssets = payload.assets.filter((a) => !a.in_library)

View File

@@ -1,14 +1,29 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: {
id: 'graph-test'
}
}
}
})
}))
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
@@ -40,12 +55,15 @@ describe('NodeWidgets', () => {
})
const mountComponent = (nodeData?: VueNodeData) => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
return mount(NodeWidgets, {
props: {
nodeData
},
global: {
plugins: [createTestingPinia()],
plugins: [pinia],
stubs: {
// Stub InputSlot to avoid complex slot registration dependencies
InputSlot: true
@@ -117,4 +135,165 @@ describe('NodeWidgets', () => {
}
)
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
slotName: 'string_a'
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
slotName: 'string_a'
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20',
storeName: 'string_a',
slotName: 'string_a'
})
const nodeData = createMockNodeData('SubgraphNode', [
duplicateA,
duplicateB,
distinct
])
const wrapper = mountComponent(nodeData)
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
})
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
slotName: 'string_a',
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
slotName: 'string_a',
options: { hidden: false }
})
const nodeData = createMockNodeData('SubgraphNode', [
hiddenDuplicate,
visibleDuplicate
])
const wrapper = mountComponent(nodeData)
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(1)
})
it('does not deduplicate entries that share names but have different widget types', () => {
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
slotName: 'string_a'
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeNodeId: '5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19',
storeName: 'string_a',
slotName: 'string_a'
})
const nodeData = createMockNodeData('SubgraphNode', [
textWidget,
comboWidget
])
const wrapper = mountComponent(nodeData)
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
name: 'string_a',
storeName: 'string_a',
slotName: 'string_a',
type: 'text',
sourceExecutionId: '65:18'
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
storeNodeId: undefined,
name: 'string_a',
storeName: 'string_a',
slotName: 'string_a',
type: 'text',
sourceExecutionId: '65:19'
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
secondTransientEntry
])
const wrapper = mountComponent(nodeData)
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: 'test_node',
name: 'test_widget',
options: { hidden: false }
})
])
const wrapper = mountComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget('graph-test', {
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
})
await nextTick()
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(0)
})
it('keeps AppInput ids mapped to node identity for selection', () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({ nodeId: 'test_node', name: 'seed_a', type: 'text' }),
createMockWidget({ nodeId: 'test_node', name: 'seed_b', type: 'text' })
])
const wrapper = mountComponent(nodeData)
const appInputWrappers = wrapper.findAllComponents({ name: 'AppInput' })
const ids = appInputWrappers.map((component) => component.props('id'))
expect(ids).toStrictEqual(['test_node', 'test_node'])
})
})

View File

@@ -21,10 +21,7 @@
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
>
<template
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="!widget.hidden && (!widget.advanced || showAdvanced)"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
@@ -120,7 +117,11 @@ import {
import { usePromotionStore } from '@/stores/promotionStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type {
LinkedUpstreamInfo,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
import { app } from '@/scripts/app'
@@ -214,6 +215,7 @@ interface ProcessedWidget {
hidden: boolean
id: string
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
type: string
@@ -241,12 +243,48 @@ function hasWidgetError(
)
}
function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: string | number | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
const storeWidgetName = widget.storeName ?? widget.name
const slotNameForIdentity = widget.slotName ?? widget.name
const stableIdentityRoot = rawWidgetId
? `node:${String(stripGraphPrefix(rawWidgetId))}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}:${index}`
return {
dedupeIdentity,
renderKey
}
}
function isWidgetVisible(options: IWidgetOptions): boolean {
const hidden = options.hidden ?? false
const advanced = options.advanced ?? false
return !hidden && (!advanced || showAdvanced.value)
}
const processedWidgets = computed((): ProcessedWidget[] => {
if (!nodeData?.widgets) return []
// nodeData.id is the local node ID; subgraph nodes need the full execution
// path (e.g. "65:63") to match keys in lastNodeErrors.
const nodeExecId = app.rootGraph
const nodeExecId = app.isGraphReady
? getExecutionIdFromNodeData(app.rootGraph, nodeData)
: String(nodeData.id ?? '')
@@ -256,10 +294,76 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const widget of widgets) {
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const storeWidgetName = widget.storeName ?? widget.name
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(mergedOptions)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
}
for (const {
widget,
mergedOptions,
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const isPromotedView = !!widget.nodeId
const vueComponent =
@@ -268,35 +372,33 @@ 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 widgetState = graphId
? widgetValueStore.getWidget(graphId, bareWidgetId, storeWidgetName)
: undefined
// Get value from store (falls back to undefined if not registered)
const value = widgetState?.value as WidgetValue
// Build options from store state, with disabled override for
// slot-linked widgets or widgets with disabled state (e.g. display-only)
const storeOptions = widgetState?.options ?? {}
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...storeOptions, disabled: true }
: storeOptions
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
graphId &&
!isPromotedView &&
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
? 'ring ring-component-node-widget-promoted'
: widget.options?.advanced
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
@@ -305,6 +407,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
options: widgetOptions,
spec: widget.spec
}
@@ -332,13 +435,14 @@ const processedWidgets = computed((): ProcessedWidget[] => {
}
result.push({
advanced: widget.options?.advanced ?? false,
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(widget, nodeExecId, nodeErrors),
hidden: widget.options?.hidden ?? false,
hidden: mergedOptions.hidden ?? false,
id: String(bareWidgetId),
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,

View File

@@ -57,7 +57,7 @@ export function useNodeSnap() {
if (!gridSizeValue) return { ...size }
const sizeArray: [number, number] = [size.width, size.height]
if (snapPoint(sizeArray, gridSizeValue)) {
if (snapPoint(sizeArray, gridSizeValue, 'ceil')) {
return { width: sizeArray[0], height: sizeArray[1] }
}
return { ...size }

View File

@@ -748,6 +748,7 @@ export function useSlotLinkInteraction({
})
function onDoubleClick(e: PointerEvent) {
if (!app.canvas) return
const { graph } = app.canvas
if (!graph) return
const node = graph.getNodeById(nodeId)
@@ -756,6 +757,7 @@ export function useSlotLinkInteraction({
node.onInputDblClick?.(index, e)
}
function onClick(e: PointerEvent) {
if (!app.canvas) return
const { graph } = app.canvas
if (!graph) return
const node = graph.getNodeById(nodeId)

View File

@@ -1,4 +1,6 @@
import type { LGraph, RendererType } from '@/lib/litegraph/src/LGraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Point as LGPoint } from '@/lib/litegraph/src/interfaces'
import type { Point } from '@/renderer/core/layout/types'
import {
@@ -15,7 +17,7 @@ interface Positioned {
size: LGPoint
}
function unprojectPosSize(item: Positioned, anchor: Point) {
function unprojectPosSize(item: Positioned, anchor: Point, graph: LGraph) {
const c = unprojectBounds(
{
x: item.pos[0],
@@ -30,6 +32,14 @@ function unprojectPosSize(item: Positioned, anchor: Point) {
item.pos[1] = c.y
item.size[0] = c.width
item.size[1] = c.height
if (LiteGraph.alwaysSnapToGrid) {
const snapTo = graph.getSnapToGridSize?.()
if (snapTo) {
snapPoint(item.pos, snapTo, 'round')
snapPoint(item.size, snapTo, 'ceil')
}
}
}
/**
@@ -60,6 +70,18 @@ export function ensureCorrectLayoutScale(
const anchor = getGraphRenderAnchor(graph)
const applySnap = (
pos: [number, number],
method: 'round' | 'ceil' | 'floor' = 'round'
) => {
if (LiteGraph.alwaysSnapToGrid) {
const snapTo = graph.getSnapToGridSize?.()
if (snapTo) {
snapPoint(pos, snapTo, method)
}
}
}
for (const node of graph.nodes) {
const c = unprojectBounds(
{
@@ -75,6 +97,9 @@ export function ensureCorrectLayoutScale(
node.pos[1] = c.y
node.size[0] = c.width
node.size[1] = c.height
applySnap(node.pos)
applySnap(node.size, 'ceil')
}
for (const reroute of graph.reroutes.values()) {
@@ -84,10 +109,11 @@ export function ensureCorrectLayoutScale(
RENDER_SCALE_FACTOR
)
reroute.pos = [p.x, p.y]
applySnap(reroute.pos)
}
for (const group of graph.groups) {
unprojectPosSize(group, anchor)
unprojectPosSize(group, anchor, graph)
}
if ('inputNode' in graph && 'outputNode' in graph) {
@@ -96,7 +122,7 @@ export function ensureCorrectLayoutScale(
graph.outputNode as SubgraphOutputNode
]) {
if (ioNode) {
unprojectPosSize(ioNode, anchor)
unprojectPosSize(ioNode, anchor, graph)
}
}
}

View File

@@ -17,6 +17,7 @@ import {
LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import { snapPoint } from '@/lib/litegraph/src/measure'
import type { Vector2 } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isCloud } from '@/platform/distribution/types'
@@ -1309,10 +1310,14 @@ export class ComfyApp {
console.error(error)
return
}
const snapTo = LiteGraph.alwaysSnapToGrid
? this.rootGraph.getSnapToGridSize()
: 0
forEachNode(this.rootGraph, (node) => {
const size = node.computeSize()
size[0] = Math.max(node.size[0], size[0])
size[1] = Math.max(node.size[1], size[1])
snapPoint(size, snapTo, 'ceil')
node.setSize(size)
if (node.widgets) {
// If you break something in the backend and want to patch workflows in the frontend

View File

@@ -384,6 +384,15 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
quickRegister('BiRefNet/pth', 'LS_LoadBiRefNetModel', 'model')
quickRegister('onnx/human-parts', 'LS_HumanPartsUltra', '')
quickRegister('lama', 'LaMa', 'lama_model')
// CogVideoX video generation models (comfyui-cogvideoxwrapper)
quickRegister('CogVideo', 'DownloadAndLoadCogVideoModel', 'model')
// Inpaint models (comfyui-inpaint-nodes)
quickRegister('inpaint', 'INPAINT_LoadInpaintModel', 'model_name')
// LayerDiffuse transparent image generation (comfyui-layerdiffuse)
quickRegister('layer_model', 'LayeredDiffusionApply', 'config')
}
return {

View File

@@ -23,6 +23,13 @@ describe(usePromotionStore, () => {
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('returns a stable empty ref for unknown node', () => {
const first = store.getPromotionsRef(graphA, nodeId)
const second = store.getPromotionsRef(graphA, nodeId)
expect(second).toBe(first)
})
it('returns entries after setPromotions', () => {
const entries = [
{ interiorNodeId: '10', widgetName: 'seed' },

View File

@@ -9,6 +9,8 @@ interface PromotionEntry {
widgetName: string
}
const EMPTY_PROMOTIONS: PromotionEntry[] = []
export const usePromotionStore = defineStore('promotion', () => {
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
@@ -62,7 +64,9 @@ export const usePromotionStore = defineStore('promotion', () => {
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
return _getPromotionsForGraph(graphId).get(subgraphNodeId) ?? []
return (
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
)
}
function getPromotions(
@@ -99,6 +103,7 @@ export const usePromotionStore = defineStore('promotion', () => {
): void {
const promotions = _getPromotionsForGraph(graphId)
const oldEntries = promotions.get(subgraphNodeId) ?? []
_decrementKeys(graphId, oldEntries)
_incrementKeys(graphId, entries)
@@ -116,6 +121,7 @@ export const usePromotionStore = defineStore('promotion', () => {
widgetName: string
): void {
if (isPromoted(graphId, subgraphNodeId, interiorNodeId, widgetName)) return
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(graphId, subgraphNodeId, [
...entries,

View File

@@ -38,6 +38,11 @@ export type SafeControlWidget = {
update: (value: WidgetValue) => void
}
export interface LinkedUpstreamInfo {
nodeId: string
outputName?: string
}
export interface SimplifiedWidget<
T extends WidgetValue = WidgetValue,
O extends IWidgetOptions = IWidgetOptions
@@ -77,6 +82,8 @@ export interface SimplifiedWidget<
tooltip?: string
controlWidget?: SafeControlWidget
linkedUpstream?: LinkedUpstreamInfo
}
export interface SimplifiedControlWidget<

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import { parsePreloadError } from './preloadErrorUtil'
describe('parsePreloadError', () => {
it('parses CSS preload error', () => {
const error = new Error(
'Unable to preload CSS for /assets/vendor-vue-core-abc123.css'
)
const result = parsePreloadError(error)
expect(result.url).toBe('/assets/vendor-vue-core-abc123.css')
expect(result.fileType).toBe('css')
expect(result.chunkName).toBe('vendor-vue-core')
expect(result.message).toBe(error.message)
})
it('parses dynamically imported module error', () => {
const error = new Error(
'Failed to fetch dynamically imported module: https://example.com/assets/vendor-three-def456.js'
)
const result = parsePreloadError(error)
expect(result.url).toBe('https://example.com/assets/vendor-three-def456.js')
expect(result.fileType).toBe('js')
expect(result.chunkName).toBe('vendor-three')
})
it('extracts URL from generic error message', () => {
const error = new Error(
'Something went wrong loading https://cdn.example.com/assets/app-9f8e7d.js'
)
const result = parsePreloadError(error)
expect(result.url).toBe('https://cdn.example.com/assets/app-9f8e7d.js')
expect(result.fileType).toBe('js')
expect(result.chunkName).toBe('app')
})
it('returns null url when no URL found', () => {
const error = new Error('Something failed')
const result = parsePreloadError(error)
expect(result.url).toBeNull()
expect(result.fileType).toBe('unknown')
expect(result.chunkName).toBeNull()
})
it('detects font file types', () => {
const error = new Error(
'Unable to preload CSS for /assets/inter-abc123.woff2'
)
const result = parsePreloadError(error)
expect(result.fileType).toBe('font')
})
it('detects image file types', () => {
const error = new Error('Unable to preload CSS for /assets/logo-abc123.png')
const result = parsePreloadError(error)
expect(result.fileType).toBe('image')
})
it('handles mjs extension', () => {
const error = new Error(
'Failed to fetch dynamically imported module: /assets/chunk-abc123.mjs'
)
const result = parsePreloadError(error)
expect(result.fileType).toBe('js')
})
it('handles URLs with query parameters', () => {
const error = new Error(
'Unable to preload CSS for /assets/style-abc123.css?v=2'
)
const result = parsePreloadError(error)
expect(result.url).toBe('/assets/style-abc123.css?v=2')
expect(result.fileType).toBe('css')
})
it('extracts chunk name from filename without hash', () => {
const error = new Error(
'Failed to fetch dynamically imported module: /assets/index.js'
)
const result = parsePreloadError(error)
expect(result.chunkName).toBe('index')
})
})

View File

@@ -0,0 +1,77 @@
type PreloadFileType = 'js' | 'css' | 'font' | 'image' | 'unknown'
interface PreloadErrorInfo {
url: string | null
fileType: PreloadFileType
chunkName: string | null
message: string
}
const CSS_PRELOAD_RE = /Unable to preload CSS for (.+)/
const JS_DYNAMIC_IMPORT_RE =
/Failed to fetch dynamically imported module:\s*(.+)/
const URL_FALLBACK_RE = /https?:\/\/[^\s"')]+/
const FONT_EXTENSIONS = new Set(['woff', 'woff2', 'ttf', 'otf', 'eot'])
const IMAGE_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'svg',
'webp',
'avif',
'ico'
])
function extractUrl(message: string): string | null {
const cssMatch = message.match(CSS_PRELOAD_RE)
if (cssMatch) return cssMatch[1].trim()
const jsMatch = message.match(JS_DYNAMIC_IMPORT_RE)
if (jsMatch) return jsMatch[1].trim()
const fallbackMatch = message.match(URL_FALLBACK_RE)
if (fallbackMatch) return fallbackMatch[0]
return null
}
function detectFileType(url: string): PreloadFileType {
const pathname = new URL(url, 'https://cloud.comfy.org').pathname
const ext = pathname.split('.').pop()?.toLowerCase()
if (!ext) return 'unknown'
// Strip query params from extension
const cleanExt = ext.split('?')[0]
if (cleanExt === 'js' || cleanExt === 'mjs') return 'js'
if (cleanExt === 'css') return 'css'
if (FONT_EXTENSIONS.has(cleanExt)) return 'font'
if (IMAGE_EXTENSIONS.has(cleanExt)) return 'image'
return 'unknown'
}
function extractChunkName(url: string): string | null {
const pathname = new URL(url, 'https://cloud.comfy.org').pathname
const filename = pathname.split('/').pop()
if (!filename) return null
// Strip extension
const nameWithoutExt = filename.replace(/\.[^.]+$/, '')
// Strip hash suffix (e.g. "vendor-vue-core-abc123" -> "vendor-vue-core")
const withoutHash = nameWithoutExt.replace(/-[a-f0-9]{6,}$/, '')
return withoutHash || null
}
export function parsePreloadError(error: Error): PreloadErrorInfo {
const message = error.message || String(error)
const url = extractUrl(message)
return {
url,
fileType: url ? detectFileType(url) : 'unknown',
chunkName: url ? extractChunkName(url) : null,
message
}
}