Compare commits

..

6 Commits

Author SHA1 Message Date
Benjamin Lu
1d5801d6ef feat: track funnel telemetry attributes (#12778)
## Summary

Adds the frontend telemetry attribution needed to analyze settings,
app-mode, and sharing funnel usage for MAR-321: re-enables three funnel
events that were disabled by default, attaches
app-mode/view-mode/dock-state context to UI click, run, and share
events, and adds a per-session `shell_layout` snapshot plus
right-side-panel toggle tracking.

## Changes

- **What**:
- Removes `setting_changed`, `template_filter_changed`, and
`ui_button_click` from the code-default `DEFAULT_DISABLED_EVENTS` lists
in the Mixpanel and PostHog providers, so these events now send by
default (see deployment note).
- `ui_button_click` now requires an `element_group`; all call sites are
tagged (`sidebar`, `queue`, `actionbar`, `breadcrumb`, `error_dialog`,
`errors_panel`, `graph_menu`, `graph_node`, `selection_toolbox`,
`node_library`, `workflow_actions`, `cloud_notification`, `app_mode`,
`top_menu`, `right_side_panel`) and the GTM provider forwards the field.
- Run events (`run_button_clicked`, GTM `run_workflow`) now carry
required `view_mode`/`is_app_mode` plus a new `dock_state`
(`docked`/`floating`), read from the `Comfy.MenuPosition.Docked`
localStorage key by a new `getActionbarDockState()` util.
- Share funnel events (`share_flow`, `share_link_opened`,
`shared_workflow_run`) now carry required `view_mode`/`is_app_mode`. A
new `useShareFlowContext()` composable dedupes the source/view-mode
context across the share dialog, URL copy field, and `useShareDialog`.
GTM `share_flow` forwards the new fields and still omits `share_id`.
- `shared_workflow_run` attribution is snapshotted onto the queued job
at queue time, so switching app/graph mode while a job runs no longer
misattributes the completion event (falls back to live values when no
snapshot exists).
- New `shell_layout` event fired once per session at graph-ready (cloud
only): `view_mode`, `is_app_mode`, `dock_state`, `actionbar_position`,
`active_sidebar_tab`, `right_side_panel_open`, `bottom_panel_open`,
`open_workflow_tabs`. Forwarded by Mixpanel and PostHog; not sent to
GTM.
- The right side panel open button (top menu) and close button now fire
`ui_button_click` (`right_side_panel_opened`/`right_side_panel_closed`),
covering the panel open-rate gap.
- **Dependencies**: None.

## Review Focus

- `view_mode`/`is_app_mode` changed from optional to required (typed as
`AppMode`) on run/share metadata — check no call sites were missed.
- The queue-time snapshot in `executionStore` (`queuedJob.viewMode ??
mode.value`) and its regression test.
- Share IDs remain limited to the providers/events that already carry
share attribution (GTM still strips `share_id`).
- `shell_layout` cadence is once per session (graph-ready idle
callback), matching the gap analysis's "session snapshot" wording.

Linear: MAR-321

Validation:
- `pnpm test:unit
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts
src/platform/telemetry/utils/getShellLayoutSnapshot.test.ts
src/platform/workflow/sharing/components/ShareWorkflowDialogContent.test.ts
src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts
src/stores/executionStore.test.ts src/components/TopMenuSection.test.ts
src/components/graph/selectionToolbox/InfoButton.test.ts
src/components/rightSidePanel/errors/useErrorActions.test.ts
src/views/GraphView.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm knip`
- `git diff --check`

## Deployment note

`telemetry_disabled_events` is currently unset in the prod/staging/test
dynamicconfig rows, so the code-default change here is what enables
these events. The remote value remains available as a kill switch, but
it **replaces** the code defaults rather than merging: if ops sets it to
re-disable an event, the list must include every event that should stay
disabled (`tab_count_tracking`, `node_search`,
`node_search_result_selected`, `help_center_*`, `workflow_created`), not
just the new ones.
2026-06-12 19:02:41 +00:00
pythongosssss
193f23e8c2 Revert "feat: default search to essentials when graph is empty" (#12814)
Reverts Comfy-Org/ComfyUI_frontend#12377
2026-06-12 18:18:34 +00:00
AustinMroz
eaa6776559 Fix broken e2e test (#12818) 2026-06-12 18:08:47 +00:00
Dante
afd42525fe B2 - refactor(billing): complete the billing facade — resubscribe/topup + status fields (FE-904) (#12622)
## What
Implements **B2 — Complete the billing facade** from the FE
billing-divergence survey. Adds the members missing from the shared
`BillingContext` so components stop bypassing `useBillingContext` with
raw `workspaceApi` calls.

Part of the billing convergence plan — **FE-904** (parent **FE-903**).

## Why this PR — an *enabling* refactor (near-zero standalone user
value)
On its own B2 changes no endpoint and is user-invisible (see
**Behavioral impact**). Its entire purpose is to be the **prerequisite**
that unblocks the rest of the convergence — it gives the facade a single
entry point and the missing capability/state surface that the next
levers depend on:

- **Unblocks B3 — repoint direct-bypass consumers (the next PR; a live
bug fix).** `SubscribeButton` (`current_tier`) and
`PostHogTelemetryProvider` (the `subscription_tier` person property)
currently read the **legacy** `useSubscription` tier, so the value is
**stale/empty for team users today** (telemetry + analytics are wrong
right now). They can only be repointed to a correct, workspace-aware
tier by sourcing it from the facade — which requires the **`tier`** (and
`renewalDate`, for `FreeTierDialog`) fields **this PR adds**. Without B2
there is literally no facade `tier` to read.
- **Unblocks B6 — orientation banners.** The 6 billing-state banners
need `billingStatus` / `subscriptionStatus`, exposed here.
- **Unblocks B1 — dispatcher flip (personal → workspace path).** B1 can
only collapse the personal/team fork once (a) every billing operation
flows through the facade — no raw `workspaceApi` bypass left — and (b)
the facade actually supports `resubscribe`/`topup`. This PR removes the
last bypasses and completes the action surface so the unified personal
path will work. (B1 itself stays gated on the BE-DATA unification.)

## Changes
- **Contract** (`composables/billing/types.ts`): `BillingActions` gains
`resubscribe()` and `topup(amountCents)`; `BillingState` gains
`billingStatus`, `subscriptionStatus`, `tier`, `renewalDate`. Exported
`BillingStatus`, `BillingSubscriptionStatus`, `CreateTopupResponse` from
`workspaceApi`.
- **Workspace adapter** (`useWorkspaceBilling`): real wiring —
`workspaceApi.resubscribe()` / `createTopup()`, surfaces
`statusData.billing_status` / `subscription_status` /
`subscription_tier` / `renewal_date`.
- **Legacy adapter** (`useLegacyBilling`): equivalents — `resubscribe` =
fresh checkout via `useSubscription`; `topup` converts **cents →
dollars** through `purchaseCredits`; `billingStatus` = `null` (no legacy
concept); `subscriptionStatus` synthesized from active/cancelled flags.
- **Dispatcher** (`useBillingContext`): proxies the new members.
- **Orphaned callers migrated** off raw `workspaceApi`:
  - `SubscriptionPanelContentWorkspace.vue` → `resubscribe()`
  - `useSubscriptionCheckout.ts` → `resubscribe()`
  - `TopUpCreditsDialogContentWorkspace.vue` → `topup(amountCents)`

## Notes
- **Unit divergence absorbed:** the facade standardizes `topup` on
**cents**; the legacy adapter converts to dollars for
`/customers/credit`.
- **FE-only, no backend dependency** — safe to merge/deploy standalone;
independent of the B1 dispatcher flip (which is gated on the BE-DATA
unification).

## Behavioral impact (verified — safe to merge/deploy standalone)
This is a structural refactor: **endpoints, request payloads, and fetch
counts are unchanged**, and there is **no user-visible change** on
OSS/Desktop or Cloud-personal.

- **OSS / Desktop** (`teamWorkspacesEnabled` off): no change. The only
B2 code that runs is the eager `useAuthActions()` in `useLegacyBilling`
setup — side-effect-free, and already instantiated transitively via
`useSubscription` today. New computeds are lazy with zero readers; new
legacy `resubscribe`/`topup` are never invoked (their callers are
team-only surfaces).
- **Cloud personal**: no change. The migrated handlers are structurally
unreachable on the legacy path (dialog/panel variant gating,
`isCancelled` gated to `!isInPersonalWorkspace`).
- **Cloud team**: same endpoints/payloads/refresh counts. **One
intentional behavioral nuance:** routing `resubscribe`/`topup` through
the facade now toggles the shared `useBillingContext().isLoading` flag
during the call (the previous raw `workspaceApi` calls did not). This is
deliberate — it aligns these two with every other facade action
(`subscribe`, `cancelSubscription`, …). Net effect is at most a brief
loading-indicator flicker in the subscription panel; no change to
network, ordering, or state correctness.

> Follow-up (pre-existing, out of scope): **FE-932** — a completed
top-up refreshes balance but not status, so `subscription.hasFunds` can
be briefly stale. Predates B2 (`main` did balance-only too); to be fixed
with B6.

> Import-cycle note: this closes `useBillingContext → useLegacyBilling →
useAuthActions → useBillingContext`. It is module-eval safe — every
cross-cycle call is at composable-runtime, none at module top level.

## Verification
- `vue-tsc --noEmit`: clean.
- `oxlint --type-aware` on touched files: 0 errors / 0 warnings.
- Runtime no-op confirmed by an adversarial code-path review across the
3 build targets (OSS / Cloud-personal / Cloud-team).
- eslint + unit tests: deferred to CI.

Survey: **FE Billing API Divergence — Personal vs Team Workspace**
(Notion) — notes D4, P6, T1, E7, E9.

> Draft: opened for early review of the facade shape and the
legacy-equivalent semantics (esp. legacy `resubscribe` = fresh checkout,
and the cents/dollars conversion).

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-12 04:02:43 +00:00
Matt Miller
0c392e53a2 fix(oauth): allow reverse-DNS custom-scheme redirects on consent (#12806)
## ELI-5

After you click Continue on the sign-in consent page, the page sends
your browser back to the app that asked. Our safety check only knew
about web-style addresses (`http://...`), so when the iOS app — whose
return address looks like `org.comfy.ios://...` — finished sign-in, the
page refused to deliver and showed "OAuth request failed." The fix:
instead of the page keeping a list of address styles it trusts, it now
asks the backend "what return address did this app register?" and goes
exactly there or nowhere. Truly dangerous addresses (ones that run code
in the page) stay banned outright.

## Problem

The consent success handler hard-allowlists `http(s)` for the
post-consent redirect (`oauthApi.ts`). That covers the loopback
redirects `comfy-desktop`/`comfy-cli` register, but rejects RFC 8252
reverse-DNS custom schemes — the callback shape native-app OAuth clients
use.

Live failure (prod, 2026-06-11, first `comfy-ios` sign-in test): user
approves consent → backend persists consent, consumes the auth request,
and mints an authorization code for `org.comfy.ios://oauth-callback` →
frontend throws `'unsafe scheme'` → user sees the generic **"OAuth
request failed"** → the code expires unused 60s later. Verified
end-to-end in the prod DB.

## Fix (final design — binding, not scheme lists)

Bind the post-consent navigation to the **challenge's registered
`redirect_uri`** (scheme + authority + path equality; the server only
appends `code`/`state` query params to the registered URI). The backend
supplies that field per-request — Comfy-Org/cloud#4230 — so the frontend
carries **zero per-client knowledge**: registering a future native
client is a backend-only change.

Layers:
1. **Executable-scheme denylist**
(`javascript:`/`data:`/`blob:`/`vbscript:`/`about:`) — unconditional;
the actual XSS line.
2. **Registration binding** when `challenge.redirect_uri` is present —
also rejects wrong-client redirects, which no scheme policy could.
3. **http(s)-only fallback** when the challenge doesn't surface
`redirect_uri` (older backend) — preserves today's behavior; the two PRs
can land in either order, but iOS sign-in needs both.

Also per the earlier review pass: navigation uses the parsed URL
(parser/sink consistency), malformed URLs throw structured errors,
single-navigation asserted.

## History

This PR went through three designs: dotted-scheme heuristic → four-lab
adversarial review (68 findings, Opus-judge consolidated) flagged the
heuristic as bypassable → exact scheme allowlist → product feedback
(hardcoding per-client schemes in shared frontend code doesn't scale and
shouldn't exist for non-product test apps) → registration binding, which
the review panel had independently flagged as the strongest option.
41/41 oauth tests passing.

## Tests
- Navigates: bound custom-scheme redirect
(`org.comfy.ios://oauth-callback?code=…` vs registered
`org.comfy.ios://oauth-callback`), http loopback (legacy fallback)
- Rejects: unbound custom scheme (fallback), wrong-client redirect
(`com.evil.app://…` vs registered iOS URI), path mutation, executable
schemes even when 'registered', malformed URLs

Related: BE-1341, BE-1350, Comfy-Org/cloud#4230.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:17:57 +00:00
AustinMroz
46526cfabd On mode toggle apply to group children (#12809)
When performing mode toggle operations (like bypass or mute) with a
group (the colored rectangles) selected, nodes contained within the
group will be considered selected and will have their state toggled.
<img width="1024" height="1024" alt="AnimateDiff_00002"
src="https://github.com/user-attachments/assets/c4e9db17-3fe8-4fd8-9012-0e9a0bc59707"
/>
2026-06-12 02:44:02 +00:00
190 changed files with 6033 additions and 5596 deletions

View File

@@ -15,7 +15,9 @@ test.describe('Download page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
await expect(page).toHaveTitle(
'Download Comfy Desktop — Run AI on Your Hardware'
)
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {

View File

@@ -1,436 +0,0 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [497.59999999999985, 468.79999999999995],
"size": [510.328125, 216.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [499.9999999999999, 225.1999633789062],
"size": [507.40625, 197.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [569.5999633789061, 732.7998535156249],
"size": [378, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1452.7999999999997, 227.59999999999997],
"size": [252, 72],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.1999999999998, 228.79999999999995],
"size": [252, 84],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.20003662109363, 570.8],
"size": [378, 130.65625],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.277734375, 340.85618896484374],
"size": [225, 184],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [["-1", "seed"]]
},
"widgets_values": [1]
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 140]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
},
{
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
"name": "seed",
"type": "INT",
"linkIds": [15],
"pos": [1131.12, 472.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"increment",
20,
8,
"euler",
"normal",
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.40.0"
},
"version": 0.4
}

View File

@@ -1,404 +0,0 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 14,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [497.59999999999985, 468.79999999999995],
"size": [510.328125, 216.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [499.9999999999999, 225.1999633789062],
"size": [507.40625, 197.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [569.5999633789061, 732.7998535156249],
"size": [378, 144],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1452.7999999999997, 227.59999999999997],
"size": [252, 72],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.1999999999998, 228.79999999999995],
"size": [252, 84],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.20003662109363, 570.8],
"size": [378, 130.65625],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.277734375, 340.85618896484374],
"size": [225, 184],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [["3", "seed"]]
},
"widgets_values": []
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 14,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 120]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [1, "increment", 20, 8, "euler", "normal", 1]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.40.0"
},
"version": 0.4
}

View File

@@ -1,439 +0,0 @@
{
"id": "ca685f6a-7402-42cc-84ae-b659b06cc8b1",
"revision": 0,
"last_node_id": 10,
"last_link_id": 15,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [498.26665242513025, 471.46666463216144],
"size": [510.328125, 252.71875],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [12]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [500.66667683919275, 227.8666280110677],
"size": [507.40625, 233.171875],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"slot_index": 0,
"links": [11]
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [570.266591389974, 735.4665120442708],
"size": [378, 216],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [13]
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [512, 512, 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1453.466512044271, 230.26666768391925],
"size": [252, 138],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 14
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"slot_index": 0,
"links": [9]
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": []
},
{
"id": 9,
"type": "SaveImage",
"pos": [1743.866658528646, 231.46666463216144],
"size": [252, 148],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [33.866689046223996, 573.4666951497395],
"size": [378, 196],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"slot_index": 0,
"links": [10]
},
{
"name": "CLIP",
"type": "CLIP",
"slot_index": 1,
"links": [3, 5]
},
{
"name": "VAE",
"type": "VAE",
"slot_index": 2,
"links": [8]
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},
{
"id": 10,
"type": "5526b801-03ef-4797-9052-cbc171512972",
"pos": [1145.9444173177085, 343.52284749348956],
"size": [225, 220],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 10
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 11
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 12
},
{
"name": "latent_image",
"type": "LATENT",
"link": 13
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [14]
}
],
"properties": {
"proxyWidgets": [
["-1", "seed"],
["3", "control_after_generate"]
]
},
"widgets_values": [1]
}
],
"links": [
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"],
[10, 4, 0, 10, 0, "MODEL"],
[11, 6, 0, 10, 1, "CONDITIONING"],
[12, 7, 0, 10, 2, "CONDITIONING"],
[13, 5, 0, 10, 3, "LATENT"],
[14, 10, 0, 8, 0, "LATENT"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "5526b801-03ef-4797-9052-cbc171512972",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 10,
"lastLinkId": 15,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [1031.12, 372.62745605468746, 120, 140]
},
"outputNode": {
"id": -20,
"bounding": [1772.7199999999998, 408.62745605468746, 120, 60]
},
"inputs": [
{
"id": "2a9d656b-f723-4cdd-897f-894835ce9c50",
"name": "model",
"type": "MODEL",
"linkIds": [1],
"localized_name": "model",
"pos": [1131.12, 392.62745605468746]
},
{
"id": "ea2d1491-db84-40fa-81e0-48008843ef25",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [4],
"localized_name": "positive",
"pos": [1131.12, 412.62745605468746]
},
{
"id": "8e021d1a-2032-4dfc-84a3-b649831bd474",
"name": "negative",
"type": "CONDITIONING",
"linkIds": [6],
"localized_name": "negative",
"pos": [1131.12, 432.62745605468746]
},
{
"id": "977613a0-f164-4004-87a4-1f70ecca7c73",
"name": "latent_image",
"type": "LATENT",
"linkIds": [2],
"localized_name": "latent_image",
"pos": [1131.12, 452.62745605468746]
},
{
"id": "42ba848c-ab1d-4eab-9f86-3693f407e253",
"name": "seed",
"type": "INT",
"linkIds": [15],
"pos": [1131.12, 472.62745605468746]
}
],
"outputs": [
{
"id": "73e0e3cd-90f1-4934-8278-2c5c64fd40f6",
"name": "LATENT",
"type": "LATENT",
"linkIds": [7],
"localized_name": "LATENT",
"pos": [1792.7199999999998, 428.62745605468746]
}
],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [1247.1199707031249, 272.23994140624995],
"size": [453.59375, 380.765625],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "model",
"name": "model",
"type": "MODEL",
"link": 1
},
{
"localized_name": "positive",
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"localized_name": "negative",
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"localized_name": "latent_image",
"name": "latent_image",
"type": "LATENT",
"link": 2
},
{
"localized_name": "seed",
"name": "seed",
"type": "INT",
"widget": {
"name": "seed"
},
"link": 15
}
],
"outputs": [
{
"localized_name": "LATENT",
"name": "LATENT",
"type": "LATENT",
"slot_index": 0,
"links": [7]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"increment",
20,
8,
"euler",
"normal",
1
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "MODEL"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 1,
"target_id": 3,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 6,
"origin_id": -10,
"origin_slot": 2,
"target_id": 3,
"target_slot": 2,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 3,
"target_id": 3,
"target_slot": 3,
"type": "LATENT"
},
{
"id": 7,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 15,
"origin_id": -10,
"origin_slot": 4,
"target_id": 3,
"target_slot": 4,
"type": "INT"
}
],
"extra": {
"workflowRendererVersion": "Vue"
}
}
]
},
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
},
"workflowRendererVersion": "Vue",
"frontendVersion": "1.35.0"
},
"version": 0.4
}

View File

@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -137,7 +137,8 @@ export const TestIds = {
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button'
convertSubgraph: 'convert-to-subgraph-button',
bypass: 'bypass-button'
},
menu: {
moreMenuContent: 'more-menu-content'

View File

@@ -1,5 +1,6 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
@@ -7,13 +8,8 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
interface ResolvedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
function widgetSourceToEntry(
source: ResolvedWidgetSource
source: PromotedWidgetSource
): PromotedWidgetEntry {
return [source.sourceNodeId, source.sourceWidgetName]
}
@@ -24,22 +20,23 @@ function previewExposureToEntry(
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
function isPromotedWidgetSource(value: unknown): value is PromotedWidgetSource {
return (
!!value &&
typeof value === 'object' &&
'sourceNodeId' in value &&
'sourceWidgetName' in value &&
typeof value.sourceNodeId === 'string' &&
typeof value.sourceWidgetName === 'string'
)
}
function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
}
/**
* Reads the promoted widgets of a subgraph host node from the live graph.
*
* Promoted widgets are now store-backed: a host input is promoted iff it
* carries a `widgetId`, and its interior source identity is resolved on demand
* by walking the subgraph input link (mirroring `resolveSubgraphInputTarget`).
* This intentionally avoids the removed `widget.sourceNodeId`/`sourceWidgetName`
* denormalization, so the helper reflects the real projection rather than a
* deleted widget-object contract.
*/
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
@@ -47,49 +44,21 @@ export async function getPromotedWidgets(
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const previewExposures = node?.serialize()?.properties?.previewExposures
if (!node?.isSubgraphNode?.())
return { widgetSources: [], previewExposures }
const { subgraph } = node
const resolveSource = (
inputName: string
): ResolvedWidgetSource | undefined => {
const inputSlot = subgraph.inputNode.slots.find(
(slot) => slot.name === inputName
)
if (!inputSlot) return undefined
for (const linkId of inputSlot.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find(
(entry) => entry.link === linkId
)
if (!targetInput) continue
if (inputNode.isSubgraphNode?.()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
return []
return [
{
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: widget.name
}
}
return undefined
}
const widgetSources = (node.inputs ?? []).flatMap((input) => {
if (!input.widgetId) return []
const source = resolveSource(input.name)
return source ? [source] : []
]
})
return { widgetSources, previewExposures }
const serializedNode = node?.serialize()
return {
widgetSources,
previewExposures: serializedNode?.properties?.previewExposures
}
},
nodeId
)
@@ -98,7 +67,7 @@ export async function getPromotedWidgets(
? parsePreviewExposures(previewExposures)
: []
return [
...widgetSources.map(widgetSourceToEntry),
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
...exposures.map(previewExposureToEntry)
]
}

View File

@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -129,23 +129,18 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// A group + a KSampler node
await comfyPage.workflow.loadWorkflow('groups/single_group')
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
// Select group + node should show bypass button
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeVisible()
// Deselect node (Only group is selected) should hide bypass button
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeHidden()
await expect(bypass).toBeVisible()
await comfyPage.keyboard.delete()
// (Only empty group is selected) should hide bypass button
await comfyPage.keyboard.selectAll()
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(bypass).toBeHidden()
})
test.describe('Color Picker', () => {

View File

@@ -217,14 +217,6 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
}
})
// Each promoted input must surface its own source value, so assert the
// name->value mapping rather than the first textbox in DOM order.
const EXPECTED_VALUE_BY_INPUT: Record<string, RegExp> = {
value: /Inner 1/,
value_1: /Inner 2/,
value_1_1: /Inner 3/
}
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
comfyPage
}) => {
@@ -236,16 +228,11 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgets).toHaveCount(4)
for (const [inputName, expectedValue] of Object.entries(
EXPECTED_VALUE_BY_INPUT
)) {
const valueWidget = outerNode.getByRole('textbox', {
name: inputName,
exact: true
})
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(expectedValue)
}
const valueWidget = outerNode
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
})
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
@@ -284,16 +271,11 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
for (const [inputName, expectedValue] of Object.entries(
EXPECTED_VALUE_BY_INPUT
)) {
const valueWidget = outerNodeAfter.getByRole('textbox', {
name: inputName,
exact: true
})
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(expectedValue)
}
const valueWidget = outerNodeAfter
.getByRole('textbox', { name: 'value' })
.first()
await comfyExpect(valueWidget).toBeVisible()
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
})
}
)

View File

@@ -53,22 +53,6 @@ test.describe(
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test('Promoted textarea materializes once when a node is converted to a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
const promotedTextarea = comfyPage.vueNodes
.getNodeLocator(String(subgraphNode.id))
.getByRole('textbox', { name: 'text', exact: true })
await expect(promotedTextarea).toHaveCount(1)
await expect(promotedTextarea).toBeVisible()
})
test.describe(
'Promoted Text Widget Lifecycle',
{ tag: ['@vue-nodes'] },

View File

@@ -1,50 +0,0 @@
import { mergeTests } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
wstest(
'Seed handling',
{ tag: '@vue-nodes' },
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
async function verifySeedControl(initializeState = true) {
const seedWidget = comfyPage.vueNodes.getWidgetByName('', 'seed')
const { input, valueControl } =
comfyPage.vueNodes.getInputNumberControls(seedWidget)
if (initializeState) {
await input.fill('1')
await valueControl.click()
await comfyPage.page.getByRole('radio', { name: 'increment' }).click()
await comfyPage.keyboard.press('Escape')
}
await execution.run()
await expect.soft(input).toHaveValue('2')
}
await test.step('seed updates on generation', async () => {
await verifySeedControl()
})
await test.step('subgraph seed updates on generation', async () => {
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await verifySeedControl()
})
for (const w of ['link-seed', 'proxy-seed', 'zit-seed']) {
await test.step(`seed updates for old workflow: ${w}`, async () => {
await comfyPage.workflow.loadWorkflow('subgraphs/' + w)
await verifySeedControl(false)
})
}
}
)

View File

@@ -484,14 +484,6 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
'subgraphs/subgraph-with-promoted-text-widget'
)
// Assert against the visible textbox the user sees, not the internal
// graph/widget projection.
const promotedTextWidgets = comfyPage.page.getByRole('textbox', {
name: 'text',
exact: true
})
await comfyExpect(promotedTextWidgets).toHaveCount(1)
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
const originalPos = await originalNode.getPosition()
@@ -505,58 +497,31 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
await comfyPage.page.keyboard.up('Alt')
}
await comfyExpect(promotedTextWidgets).toHaveCount(2)
})
test(
'Cloning a subgraph node preserves edited promoted widget values on original and clone',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const editedValue = 'Edited prompt that must survive cloning'
const originalTextbox = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(originalTextbox).toBeVisible()
await expect(originalTextbox).toHaveValue('')
await originalTextbox.fill(editedValue)
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
await originalNode.click('title')
await comfyPage.clipboard.copy()
await comfyPage.clipboard.paste()
async function collectSubgraphNodeIds() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
}
await expect
.poll(async () => (await collectSubgraphNodeIds()).length)
.toBeGreaterThan(1)
const subgraphNodeIds = await collectSubgraphNodeIds()
for (const nodeId of subgraphNodeIds) {
const textbox = comfyPage.vueNodes
.getNodeLocator(nodeId)
.getByRole('textbox', { name: 'text' })
await expect(
textbox,
`node ${nodeId} promoted text widget reset to default after clone`
).toHaveValue(editedValue)
}
async function collectSubgraphNodeIds() {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph.nodes
.filter(
(n) =>
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
.map((n) => String(n.id))
})
}
)
await expect
.poll(async () => (await collectSubgraphNodeIds()).length)
.toBeGreaterThan(1)
const subgraphNodeIds = await collectSubgraphNodeIds()
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
})
})
test.describe('Duplicate ID Remapping', () => {

View File

@@ -3,6 +3,8 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
const CREATE_GROUP_HOTKEY = 'Control+g'
@@ -217,4 +219,40 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
)
}).toPass({ timeout: 5000 })
})
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('.')
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
const toggleBypass = () =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
const bypassCount = () =>
comfyPage.page.evaluate(
() => graph!.nodes.filter((node) => node.mode === 4).length
)
expect(await bypassCount()).toBe(0)
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
await expect.poll(groupCount, 'create group').toBe(1)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.select()
await toggleBypass()
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
await comfyPage.page.keyboard.down('Shift')
await ksampler.select()
await comfyPage.page.keyboard.up('Shift')
await toggleBypass()
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
})
})

View File

@@ -87,6 +87,14 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -110,6 +118,9 @@ function createWrapper({
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
@@ -266,6 +277,19 @@ describe('TopMenuSection', () => {
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('tracks right side panel opens', async () => {
const { user } = createWrapper()
await user.click(
screen.getByRole('button', { name: 'Toggle properties panel' })
)
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)

View File

@@ -78,7 +78,7 @@
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
@@ -148,6 +148,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
@@ -282,6 +283,14 @@ const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
function openRightSidePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)

View File

@@ -222,7 +222,8 @@ watch(visible, async (newVisible) => {
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start'
button_id: 'actionbar_run_handle_drag_start',
element_group: 'actionbar'
})
})

View File

@@ -131,7 +131,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected'
button_id: 'queue_mode_option_run_on_change_selected',
element_group: 'queue'
})
queueMode.value = 'change'
}
@@ -145,7 +146,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
button_id: 'queue_mode_option_run_instant_selected',
element_group: 'queue'
})
queueMode.value = 'instant-idle'
}
@@ -237,7 +239,8 @@ const queuePrompt = async (e: Event) => {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'queue'
})
}

View File

@@ -88,7 +88,8 @@ const home = computed(() => ({
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
button_id: 'breadcrumb_subgraph_root_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -103,7 +104,8 @@ const items = computed(() => {
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
button_id: 'breadcrumb_subgraph_item_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -10,7 +10,7 @@ import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetEntityId } from '@/world/entityIds'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -110,8 +110,8 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
}
}
function removeSelectedWidgetId(widgetId: WidgetId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
@@ -139,11 +139,11 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const widgetId = widget.widgetId
if (!widgetId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === widgetId)
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index === -1)
appModeStore.selectedInputs.push([widgetId, widget.name, undefined])
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -172,7 +172,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
resolvedInputs.value.map(
(entry) =>
[entry.widgetId, getWidgetBounding(entry)] as [
[entry.entityId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
@@ -220,7 +220,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<template v-for="entry in resolvedInputs" :key="entry.widgetId">
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
@@ -239,7 +239,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedWidgetId(entry.widgetId)"
:remove="() => removeSelectedEntityId(entry.entityId)"
/>
</template>
</DraggableList>

View File

@@ -60,7 +60,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
const { entityId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
@@ -70,7 +70,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
return vueWidget.entityId === entityId
})
if (!matchingWidget) return []
@@ -79,7 +79,7 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: widgetId,
key: entityId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,

View File

@@ -1,13 +1,12 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetEntityId } from '@/world/entityIds'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
@@ -23,29 +22,18 @@ vi.mock('@/scripts/app', () => ({
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetId
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
inputs: [],
isSubgraphNode: () => false,
widgets: widgetNames.map((name) => ({
name,
widgetId: `${rootGraphId}:${id}:${name}` as WidgetId
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
}))
})
}
function makeSubgraphNode(id: number, inputs: INodeInputSlot[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
inputs,
isSubgraphNode: () => true,
widgets: []
})
}
function setRootGraphNodes(nodes: LGraphNode[]) {
vi.mocked(app.rootGraph).nodes = nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn(
@@ -100,27 +88,4 @@ describe('useResolvedSelectedInputs', () => {
expect(resolved.value[0]?.status).toBe('unknown')
})
it('resolves promoted subgraph inputs from their host input widgetId', () => {
const node = makeSubgraphNode(1, [
fromPartial<INodeInputSlot>({
name: 'seed',
label: 'renamed_seed',
widgetId: entitySeed
})
])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]).toMatchObject({
status: 'resolved',
node,
displayName: 'seed',
widget: { name: 'seed', label: 'renamed_seed', widgetId: entitySeed }
})
})
})

View File

@@ -1,19 +1,18 @@
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, triggerRef } from 'vue'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetId } from '@/types/widgetId'
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
export type ResolvedSelection =
| {
status: 'resolved'
widgetId: WidgetId
entityId: WidgetEntityId
node: LGraphNode
widget: IBaseWidget
displayName: string
@@ -21,7 +20,7 @@ export type ResolvedSelection =
}
| {
status: 'unknown'
widgetId: WidgetId
entityId: WidgetEntityId
displayName: string
config?: InputWidgetConfig
}
@@ -55,19 +54,16 @@ export function useResolvedSelectedInputs() {
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([widgetId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetId(widgetId)) return []
const { nodeId, name } = parseWidgetId(widgetId)
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
const node = rootGraph.getNodeById(nodeId)
const widgets = node?.isSubgraphNode()
? promotedInputWidgets(node)
: node?.widgets
const widget = widgets?.find((w) => w.name === name)
const widget = node?.widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', widgetId, displayName, config }]
return [{ status: 'unknown', entityId, displayName, config }]
}
return [
{ status: 'resolved', widgetId, node, widget, displayName, config }
{ status: 'resolved', entityId, node, widget, displayName, config }
]
}
)

View File

@@ -40,7 +40,8 @@ function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
button_id: source,
element_group: 'workflow_actions'
})
}
}

View File

@@ -101,7 +101,8 @@ const reportOpen = ref(false)
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked'
button_id: 'error_dialog_show_report_clicked',
element_group: 'error_dialog'
})
reportOpen.value = true
}

View File

@@ -25,7 +25,8 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
button_id: 'error_dialog_find_existing_issues_clicked',
element_group: 'error_dialog'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`

View File

@@ -57,85 +57,154 @@ function drawFrame(canvas: LGraphCanvas) {
canvas.onDrawForeground?.({} as CanvasRenderingContext2D, new Rectangle())
}
describe('DomWidgets positioning', () => {
describe('DomWidgets transition grace characterization', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('positions an active visible widget relative to its owning node', () => {
it('applies transition grace for exactly one frame when override exists but is not active', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const node = createNode(graph, 1, 'host', [100, 200])
const widget = createWidget('widget-pos', node, 14)
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [100, 200])
const overrideNode = createNode(graphB, 2, 'override', [600, 700])
const widget = createWidget('widget-transition', interiorNode, 14)
const overrideWidget = createWidget('override-widget', overrideNode, 22)
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(graph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([110, 224])
})
it('hides a widget whose owning node is in a different graph', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const currentGraph = new LGraph()
const otherGraph = new LGraph()
const node = createNode(otherGraph, 1, 'host', [100, 200])
const widget = createWidget('widget-other-graph', node, 14)
domWidgetStore.registerWidget(widget)
const canvas = createCanvas(currentGraph)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
})
drawFrame(canvas)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
expect(widgetState.visible).toBe(false)
})
it('hides an inactive widget', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graph = new LGraph()
const node = createNode(graph, 1, 'host', [0, 0])
const widget = createWidget('widget-inactive', node, 10)
domWidgetStore.registerWidget(widget)
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
widgetState.visible = true
widgetState.pos = [321, 654]
const canvas = createCanvas(graph)
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
render(DomWidgets, {
global: { stubs: { DomWidget: true } }
global: {
stubs: {
DomWidget: true
}
}
})
drawFrame(canvas)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([321, 654])
drawFrame(canvas)
expect(widgetState.visible).toBe(false)
})
it('uses override positioning while override node is in current graph even when widget is inactive', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [10, 20])
const overrideNode = createNode(graphB, 2, 'override', [300, 400])
const widget = createWidget('widget-override-active', interiorNode, 8)
const overrideWidget = createWidget(
'override-position-source',
overrideNode,
18
)
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(widget.id)
const widgetState = domWidgetStore.widgetStates.get(widget.id)
if (!widgetState) throw new Error('Widget state not registered')
const canvas = createCanvas(graphB)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
drawFrame(canvas)
expect(widgetState.visible).toBe(false)
expect(widgetState.visible).toBe(true)
expect(widgetState.pos).toEqual([310, 428])
})
it('cleans orphaned transition-grace ids after widget removal', () => {
const canvasStore = useCanvasStore()
const domWidgetStore = useDomWidgetStore()
const graphA = new LGraph()
const graphB = new LGraph()
const interiorNode = createNode(graphA, 1, 'interior', [0, 0])
const overrideNode = createNode(graphB, 2, 'override', [200, 200])
const canvas = createCanvas(graphA)
canvasStore.canvas = canvas
render(DomWidgets, {
global: {
stubs: {
DomWidget: true
}
}
})
const oldWidget = createWidget('shared-widget-id', interiorNode, 10)
const overrideWidget = createWidget(
'shared-override-widget',
overrideNode,
14
)
domWidgetStore.registerWidget(oldWidget)
domWidgetStore.setPositionOverride(oldWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(oldWidget.id)
drawFrame(canvas)
domWidgetStore.unregisterWidget(oldWidget.id)
drawFrame(canvas)
const replacementWidget = createWidget('shared-widget-id', interiorNode, 10)
domWidgetStore.registerWidget(replacementWidget)
domWidgetStore.setPositionOverride(replacementWidget.id, {
node: overrideNode,
widget: overrideWidget
})
domWidgetStore.deactivateWidget(replacementWidget.id)
const replacementState = domWidgetStore.widgetStates.get(
replacementWidget.id
)
if (!replacementState) throw new Error('Replacement widget missing state')
replacementState.visible = true
replacementState.pos = [999, 999]
drawFrame(canvas)
expect(replacementState.visible).toBe(true)
expect(replacementState.pos).toEqual([999, 999])
})
})

View File

@@ -21,6 +21,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
const domWidgetStore = useDomWidgetStore()
const overrideTransitionGrace = new Set<string>()
const widgetStates = computed(() => [...domWidgetStore.widgetStates.values()])
@@ -30,16 +31,47 @@ const updateWidgets = () => {
const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph
const seenWidgetIds = new Set<string>()
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
seenWidgetIds.add(widget.id)
if (!widget.isVisible() || !widgetState.active) {
// Use position override only when the override node (SubgraphNode) is
// in the current graph. When the user enters the subgraph, the override
// node is no longer visible — fall back to the widget's own node.
// Use graph reference equality (IDs are not unique across graphs).
const override = widgetState.positionOverride
const useOverride = !!override && currentGraph === override.node.graph
const inOverrideTransitionGap =
!!override && !useOverride && !widgetState.active
const useTransitionGrace =
inOverrideTransitionGap && !overrideTransitionGrace.has(widget.id)
if (useTransitionGrace) {
overrideTransitionGrace.add(widget.id)
} else if (!inOverrideTransitionGap) {
overrideTransitionGrace.delete(widget.id)
}
// Early exit for non-visible widgets.
// When a position override is active (widget promoted to SubgraphNode),
// the interior widget's `active` flag is false (its node is in the
// subgraph, not the current graph) — bypass that check.
if (
!widget.isVisible() ||
(!widgetState.active && !useOverride && !useTransitionGrace)
) {
widgetState.visible = false
continue
}
const posNode = widget.node
// During graph transitions, hold the previous position for one frame
// so promoted widgets don't briefly disappear before activation flips.
if (useTransitionGrace) continue
const posNode = useOverride ? override.node : widget.node
const posWidget = useOverride ? override.widget : widget
const isInCorrectGraph = posNode.graph === currentGraph
const nodeVisible = lgCanvas.isNodeVisible(posNode)
@@ -53,16 +85,22 @@ const updateWidgets = () => {
const margin = widget.margin
widgetState.pos = [
posNode.pos[0] + margin,
posNode.pos[1] + margin + widget.y
posNode.pos[1] + margin + posWidget.y
]
widgetState.size = [
(widget.width ?? posNode.width) - margin * 2,
(widget.computedHeight ?? 50) - margin * 2
(posWidget.width ?? posNode.width) - margin * 2,
(posWidget.computedHeight ?? 50) - margin * 2
]
widgetState.zIndex = getDomWidgetZIndex(posNode, currentGraph)
widgetState.readonly = lgCanvas.read_only
}
}
for (const widgetId of overrideTransitionGrace) {
if (!seenWidgetIds.has(widgetId)) {
overrideTransitionGrace.delete(widgetId)
}
}
}
const canvasStore = useCanvasStore()

View File

@@ -218,7 +218,8 @@ onMounted(() => {
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked'
button_id: 'graph_menu_minimap_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
@@ -228,7 +229,8 @@ const onMinimapToggleClick = () => {
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked'
button_id: 'graph_menu_hide_links_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}

View File

@@ -101,6 +101,7 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const {
hasAnySelection,
hasGroupedNodesSelection,
hasMultipleSelection,
isSingleNode,
isSingleSubgraph,
@@ -118,7 +119,10 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
const showBypass = computed(
() =>
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
isSingleNode.value ||
isSingleSubgraph.value ||
hasMultipleSelection.value ||
hasGroupedNodesSelection.value
)
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
const showMaskEditor = computed(() => isSingleImageNode.value)

View File

@@ -65,7 +65,8 @@ describe('InfoButton', () => {
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
})

View File

@@ -24,7 +24,8 @@ const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
}
</script>

View File

@@ -54,7 +54,7 @@ vi.mock('@/platform/settings/settingStore', () => ({
})
}))
function createWidgetState(disabled: boolean): DomWidgetState {
function createWidgetState(overrideDisabled: boolean): DomWidgetState {
const domWidgetStore = useDomWidgetStore()
const node = createMockLGraphNode({
id: 1,
@@ -70,10 +70,14 @@ function createWidgetState(disabled: boolean): DomWidgetState {
value: '',
options: {},
node,
computedDisabled: disabled
computedDisabled: false
})
domWidgetStore.registerWidget(widget)
domWidgetStore.setPositionOverride(widget.id, {
node: createMockLGraphNode({ id: 2 }),
widget: { computedDisabled: overrideDisabled } as DomWidgetState['widget']
})
const state = domWidgetStore.widgetStates.get(widget.id)
if (!state) throw new Error('Expected registered DomWidgetState')
@@ -94,7 +98,7 @@ describe('DomWidget disabled style', () => {
vi.clearAllMocks()
})
it('uses disabled style when widget is computedDisabled', async () => {
it('uses disabled style when promoted override widget is computedDisabled', async () => {
const widgetState = createWidgetState(true)
const { container } = render(DomWidget, {
props: {

View File

@@ -69,7 +69,11 @@ const updateDomClipping = () => {
return
}
const isSelected = selectedNode === widgetState.widget.node
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph ? override.node : widgetState.widget.node
const isSelected = selectedNode === ownerNode
const renderArea = selectedNode?.renderArea
const offset = lgCanvas.ds.offset
const scale = lgCanvas.ds.scale
@@ -100,7 +104,10 @@ const updateDomClipping = () => {
const { left, top } = useElementBounding(canvasStore.getCanvas().canvas)
function composeStyle() {
const isDisabled = widget.computedDisabled
const override = widgetState.positionOverride
const isDisabled = override
? (override.widget.computedDisabled ?? widget.computedDisabled)
: widget.computedDisabled
style.value = {
...positionStyle.value,
@@ -160,7 +167,13 @@ onMounted(() => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const ownerNode = widgetState.widget.node
const override = widgetState.positionOverride
const overrideInGraph =
override && lgCanvas.graph?.getNodeById(override.node.id)
const ownerNode = overrideInGraph
? override.node
: widgetState.widget.node
lgCanvas.selectNode(ownerNode)
lgCanvas.bringToFront(ownerNode)
}

View File

@@ -14,6 +14,7 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -106,6 +107,10 @@ const isSingleSubgraphNode = computed(() => {
})
function closePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_closed',
element_group: 'right_side_panel'
})
rightSidePanelStore.closePanel()
}

View File

@@ -58,7 +58,8 @@ describe('useErrorActions', () => {
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked'
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
@@ -123,7 +124,8 @@ describe('useErrorActions', () => {
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked'
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(

View File

@@ -9,7 +9,8 @@ export function useErrorActions() {
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
@@ -25,7 +26,8 @@ export function useErrorActions() {
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(

View File

@@ -12,15 +12,14 @@ import {
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
@@ -147,17 +146,16 @@ function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
): boolean {
const source = widgetPromotedSource(widgetNode, widget)
return parents.some((parent) => {
if (source) {
if (isPromotedWidgetView(widget)) {
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? source.nodeId
? widget.sourceNodeId
: String(widgetNode.id)
return isWidgetPromotedOnSubgraphNode(parent, {
sourceNodeId: interiorNodeId,
sourceWidgetName: source.widgetName
sourceWidgetName: widget.sourceWidgetName
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
@@ -236,10 +234,7 @@ function navigateToErrorTab() {
rightSidePanelStore.openPanel('errors')
}
function setWidgetValue(widget: IBaseWidget, value: WidgetValue) {
// Store-backed widgets (interior node widgets and promoted subgraph inputs)
// are addressed by widgetId; writing there keeps the displayed value in sync.
if (widget.widgetId) useWidgetValueStore().setValue(widget.widgetId, value)
function writeWidgetValue(widget: IBaseWidget, value: WidgetValue) {
widget.value = value
widget.callback?.(value)
canvasStore.canvas?.setDirty(true, true)
@@ -250,18 +245,18 @@ function handleResetAllWidgets() {
const spec = nodeDefStore.getInputSpecForWidget(widgetNode, widget.name)
const defaultValue = getWidgetDefaultValue(spec)
if (defaultValue !== undefined) {
setWidgetValue(widget, defaultValue)
writeWidgetValue(widget, defaultValue)
}
}
}
function handleWidgetValueUpdate(widget: IBaseWidget, newValue: WidgetValue) {
if (newValue === undefined) return
setWidgetValue(widget, newValue)
writeWidgetValue(widget, newValue)
}
function handleWidgetReset(widget: IBaseWidget, newValue: WidgetValue) {
setWidgetValue(widget, newValue)
writeWidgetValue(widget, newValue)
}
defineExpose({

View File

@@ -1,127 +0,0 @@
import { render } from '@testing-library/vue'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import TabSubgraphInputs from './TabSubgraphInputs.vue'
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { rightSidePanel: { inputs: 'Inputs', inputsNone: 'None' } } }
})
const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
rows: []
}
const SectionWidgetsStub = {
props: ['widgets', 'node', 'parents'],
setup(props: Record<string, unknown>) {
captured.rows = props.widgets as {
node: LGraphNode
widget: IBaseWidget
}[]
return () => null
}
}
function buildHostWithPromotedSeed(): {
host: SubgraphNode
sourceNode: LGraphNode
} {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const graph = host.graph as LGraph
graph.add(host)
const sourceNode = new LGraphNode('Sampler')
const input = sourceNode.addInput('seed', 'INT')
const seedWidget = sourceNode.addWidget('number', 'seed', 42, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(sourceNode)
promoteValueWidgetViaSubgraphInput(host, sourceNode, seedWidget)
return { host, sourceNode }
}
function renderPanel(node: SubgraphNode) {
return render(TabSubgraphInputs, {
props: { node },
global: {
plugins: [i18n],
stubs: {
SectionWidgets: SectionWidgetsStub,
AsyncSearchInput: true,
CollapseToggleButton: true
}
}
})
}
describe('TabSubgraphInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
captured.rows = []
vi.clearAllMocks()
})
it('lists a subgraph node promoted widget as a store-backed parameter row', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow).toBeDefined()
expect(seedRow?.node.id).toBe(host.id)
expect(seedRow?.widget.type).toBe('number')
expect(seedRow?.widget.widgetId).toBe(
widgetId(host.rootGraph.id, host.id, 'seed')
)
expect(seedRow?.widget.value).toBe(42)
})
it('reflects the current host widget value from the store', () => {
const { host } = buildHostWithPromotedSeed()
const id = widgetId(host.rootGraph.id, host.id, 'seed')
useWidgetValueStore().setValue(id, 7)
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')
expect(seedRow?.widget.value).toBe(7)
})
it('reflects value changes through the same descriptor without rebuilding it', () => {
const { host } = buildHostWithPromotedSeed()
renderPanel(host)
const seedRow = captured.rows.find((row) => row.widget.name === 'seed')!
expect(seedRow.widget.value).toBe(42)
// A value edit must not require a new descriptor object: the same row
// reflects the store change via its live getter, keeping render keys stable.
useWidgetValueStore().setValue(
widgetId(host.rootGraph.id, host.id, 'seed'),
100
)
expect(seedRow.widget.value).toBe(100)
})
})

View File

@@ -3,13 +3,14 @@ import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
@@ -44,6 +45,32 @@ const isAllCollapsed = computed({
})
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
watch(
focusedSection,
async (section) => {
@@ -66,7 +93,7 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
return promotedInputWidgets(node).map((widget) => ({ node, widget }))
return getPromotedWidgets().map((widget) => ({ node, widget }))
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {

View File

@@ -5,9 +5,8 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demotePromotedInput,
demoteWidget,
isLinkedPromotion,
promoteWidget
@@ -17,7 +16,6 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -47,10 +45,8 @@ const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
const isLinked = computed(() => {
if (!node.isSubgraphNode()) return false
const source = widgetPromotedSource(node, widget)
if (!source) return false
return isLinkedPromotion(node, source.nodeId, source.widgetName)
if (!node.isSubgraphNode() || !isPromotedWidgetView(widget)) return false
return isLinkedPromotion(node, widget.sourceNodeId, widget.sourceWidgetName)
})
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
const favoriteNode = computed(() =>
@@ -68,16 +64,9 @@ const defaultValue = computed(() => getWidgetDefaultValue(inputSpec.value))
const hasDefault = computed(() => defaultValue.value !== undefined)
const currentValue = computed(
() =>
(widget.widgetId &&
useWidgetValueStore().getWidget(widget.widgetId)?.value) ??
widget.value
)
const isCurrentValueDefault = computed(() => {
if (!hasDefault.value) return true
return isEqual(currentValue.value, defaultValue.value)
return isEqual(widget.value, defaultValue.value)
})
async function handleRename() {
@@ -88,15 +77,21 @@ async function handleRename() {
function handleHideInput() {
if (!parents?.length) return
const source = widgetPromotedSource(node, widget)
if (source) {
if (isPromotedWidgetView(widget)) {
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id) ? source.nodeId : String(node.id)
demotePromotedInput(parent, {
sourceNodeId,
sourceWidgetName: source.widgetName
})
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -7,8 +7,6 @@ import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -44,6 +42,10 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry',
() => ({
@@ -94,6 +96,43 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
} as IBaseWidget
}
/**
* Creates a mock PromotedWidgetView that mirrors the real class:
* properties like name, type, value, options are prototype getters,
* NOT own properties — so object spread loses them.
*/
function createMockPromotedWidgetView(
sourceOptions: IBaseWidget['options'] = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
): IBaseWidget {
class MockPromotedWidgetView {
readonly sourceNodeId = '42'
readonly sourceWidgetName = 'ckpt_name'
readonly serialize = false
get name(): string {
return 'ckpt_name'
}
get type(): string {
return 'combo'
}
get value(): unknown {
return 'model_a.safetensors'
}
get options(): IBaseWidget['options'] {
return sourceOptions
}
get label(): string | undefined {
return undefined
}
get y(): number {
return 0
}
}
return fromAny<IBaseWidget, unknown>(new MockPromotedWidgetView())
}
function renderWidgetItem(
widget: IBaseWidget,
node: LGraphNode = createMockNode()
@@ -128,7 +167,7 @@ describe('WidgetItem', () => {
vi.clearAllMocks()
})
describe('widget state rendering', () => {
describe('promoted widget options', () => {
it('passes options from a regular widget to the widget component', () => {
const widget = createMockWidget({
options: { values: ['a', 'b', 'c'] }
@@ -141,63 +180,35 @@ describe('WidgetItem', () => {
})
})
it('passes options from widget state to the widget component', () => {
it('passes options from a PromotedWidgetView to the widget component', () => {
const expectedOptions = {
values: ['model_a.safetensors', 'model_b.safetensors']
}
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'ckpt_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: expectedOptions
})
const widget = createMockPromotedWidgetView(expectedOptions)
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.options).toEqual(expectedOptions)
})
it('passes type from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, type: 'string' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
it('passes type from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.type).toBe('combo')
})
it('passes name from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, name: 'source_name' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
it('passes name from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.name).toBe('ckpt_name')
})
it('passes value from widget state to the widget component', () => {
const id = widgetId('test-graph-id', 1, 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: 'model_a.safetensors',
options: { values: ['model_a.safetensors'] }
})
it('passes value from a PromotedWidgetView to the widget component', () => {
const widget = createMockPromotedWidgetView()
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -16,12 +17,11 @@ import {
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
useWidgetValueStore,
stripGraphPrefix
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { renameWidget } from '@/utils/widgetUtil'
@@ -67,32 +67,35 @@ const widgetComponent = computed(() => {
return component || WidgetLegacy
})
function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
const source = resolvePromotedWidgetSource(node, widget)
return source ?? { node, widget }
}
const simplifiedWidget = computed((): SimplifiedWidget => {
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
const graphId = node.graph?.rootGraph?.id
const bareNodeId = stripGraphPrefix(String(node.id))
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
: graphId
? widgetValueStore.getWidget(widgetId(graphId, bareNodeId, widget.name))
: undefined
const widgetName = widgetState?.name ?? widget.name
const widgetType = widgetState?.type ?? widget.type
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
const widgetState = graphId
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
: undefined
return {
name: widgetName,
type: widgetType,
name: widget.name,
type: widget.type,
value: widgetState?.value ?? widget.value,
label: widgetState?.label ?? widget.label,
options: widgetState?.options ?? widget.options,
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),
controlWidget: getControlWidget(widget)
spec: nodeDefStore.getInputSpecForWidget(sourceNode, sourceWidget.name),
controlWidget: getControlWidget(sourceWidget)
}
})
const displayNodeName = computed((): string | null => {
if (!node) return null
const sourceNodeName = computed((): string | null => {
const sourceNode = resolvePromotedWidgetSource(node, widget)?.node ?? node
if (!sourceNode) return null
const fallbackNodeTitle = t('rightSidePanel.fallbackNodeTitle')
return resolveNodeDisplayName(node, {
return resolveNodeDisplayName(sourceNode, {
emptyLabel: fallbackNodeTitle,
untitledLabel: fallbackNodeTitle,
st
@@ -164,10 +167,10 @@ const displayLabel = customRef((track, trigger) => {
/>
<span
v-if="(showNodeName || hasParents) && displayNodeName"
v-if="(showNodeName || hasParents) && sourceNodeName"
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
>
{{ displayNodeName }}
{{ sourceNodeName }}
</span>
<div
v-if="!hiddenWidgetActions"

View File

@@ -14,9 +14,8 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import SubgraphEditor from './SubgraphEditor.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type DraggableList from '@/components/common/DraggableList.vue'
@@ -168,20 +167,11 @@ describe('SubgraphEditor', () => {
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
const rowFor = (sourceNode: LGraphNode) => {
const input = host.inputs.find((input) => {
if (!input.widgetId) return false
const target = resolveSubgraphInputTarget(host, input.name)
return target?.nodeId === String(sourceNode.id)
})!
return {
kind: 'promoted',
node: sourceNode,
input,
widget: promotedInputWidget(input)!
}
}
const reversed = [rowFor(secondNode), rowFor(firstNode)] as PromotedRow[]
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
const reversed = [
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
] as PromotedRow[]
listSetter?.(reversed)
await nextTick()
@@ -192,42 +182,6 @@ describe('SubgraphEditor', () => {
).toEqual(['second', 'first'])
})
it('moves a widget to shown when promoted from the hidden section', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const sourceNode = new LGraphNode('SourceNode')
subgraph.add(sourceNode)
const sourceInput = sourceNode.addInput('first', 'STRING')
const sourceWidget = sourceNode.addWidget('text', 'first', '', () => {})
sourceInput.widget = { name: sourceWidget.name }
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
const hidden = screen.getByTestId('subgraph-editor-hidden-section')
await userEvent.click(within(hidden).getByTestId('subgraph-widget-toggle'))
await nextTick()
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first'])
})
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
@@ -259,13 +213,13 @@ describe('SubgraphEditor', () => {
}
})
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(2)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const hideAllLink = within(shown).getByText('Hide all')
await userEvent.click(hideAllLink)
expect(host.inputs.filter((input) => input.widgetId)).toHaveLength(0)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
})
it('removes the exposure when a preview row without a real source widget is demoted', async () => {

View File

@@ -5,8 +5,9 @@ import { computed, onMounted, shallowRef, watch } from 'vue'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demotePromotedInput,
demoteWidget,
getPromotableWidgets,
isLinkedPromotion,
@@ -15,14 +16,8 @@ import {
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import {
promotedInputSource,
promotedInputWidget
} from '@/core/graph/subgraph/promotedInputWidget'
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -38,8 +33,7 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
input: INodeInputSlot
widget: IBaseWidget
widget: PromotedWidgetView
}
type PreviewRow = {
kind: 'preview'
@@ -60,23 +54,11 @@ const activeNode = computed(() => {
return undefined
})
const promotedRows = shallowRef<readonly PromotedRow[]>([])
function buildPromotedRows(node: SubgraphNode): PromotedRow[] {
return node.inputs.flatMap((input): PromotedRow[] => {
const widget = promotedInputWidget(input)
if (!widget) return []
const source = promotedInputSource(node, input)
if (!source) return []
const sourceNode = node.subgraph._nodes_by_id[source.nodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, input, widget }]
})
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
function refreshPromotedWidgets() {
promotedWidgets.value = activeNode.value?.widgets ?? []
}
function refreshPromotedRows() {
const node = activeNode.value
promotedRows.value = node ? buildPromotedRows(node) : []
}
watch(activeNode, refreshPromotedRows, { immediate: true })
watch(activeNode, refreshPromotedWidgets, { immediate: true })
useEventListener(
() => activeNode.value?.subgraph.events,
[
@@ -86,29 +68,34 @@ useEventListener(
'removing-input',
'inputs-reordered'
],
refreshPromotedRows
refreshPromotedWidgets
)
function promotedRowSource(row: PromotedRow): PromotedSource | undefined {
const node = activeNode.value
return node ? promotedInputSource(node, row.input) : undefined
}
const activeRows = computed<ActiveRow[]>(() => {
const node = activeNode.value
if (!node) return []
return [...promotedRows.value, ...getActivePreviewRows(node)]
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
})
const activePromotedRows = computed<PromotedRow[]>({
get() {
return [...promotedRows.value]
const node = activeNode.value
return node ? getActivePromotedRows(node) : []
},
set(value: PromotedRow[]) {
updateActivePromotedRows(value, activePromotedRows.value)
}
})
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, widget }]
})
}
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const hostLocator = String(node.id)
const rootGraphId = node.rootGraph.id
@@ -143,7 +130,7 @@ function updateActivePromotedRows(
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map((row) => ({ widgetId: row.widget.widgetId }))
value.map((row) => row.widget)
)
}
refreshPromotedWidgetRendering()
@@ -164,11 +151,9 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
})
function activeRowSourceKey(row: ActiveRow): string {
if (row.kind !== 'promoted')
return `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
const source = promotedRowSource(row)
return `${source?.nodeId ?? row.node.id}:${source?.widgetName ?? ''}`
return row.kind === 'promoted'
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
}
const candidateWidgets = computed<WidgetItem[]>(() => {
@@ -243,16 +228,18 @@ function rowDisplayName(row: ActiveRow): string {
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
const source = promotedRowSource(row)
return (
!!activeNode.value &&
!!source &&
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
isLinkedPromotion(
activeNode.value,
String(row.node.id),
row.widget.sourceWidgetName
)
)
}
function promotedRowKey(row: PromotedRow): string {
return `${row.node.id}: ${row.widget.name}`
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
}
function rowKey(row: ActiveRow): string {
@@ -269,14 +256,7 @@ function demoteRow(row: ActiveRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
if (row.kind === 'promoted') {
const source = promotedRowSource(row)
if (source) {
demotePromotedInput(subgraphNode, {
sourceNodeId: source.nodeId,
sourceWidgetName: source.widgetName
})
}
refreshPromotedWidgetRendering()
demoteWidget(row.node, row.widget, [subgraphNode])
return
}
if (row.realWidget) {
@@ -294,18 +274,13 @@ function demoteRow(row: ActiveRow) {
function promotePromotedRow(row: PromotedRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
const source = promotedRowSource(row)
const sourceWidget = source
? row.node.widgets?.find((widget) => widget.name === source.widgetName)
: undefined
if (sourceWidget) promoteWidget(row.node, sourceWidget, [subgraphNode])
promoteWidget(row.node, row.widget, [subgraphNode])
}
function promoteCandidate([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
refreshPromotedRows()
}
function showAll() {

View File

@@ -5,12 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
@@ -74,8 +71,7 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -83,8 +79,7 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
template: '<div data-testid="search-content-v2"></div>'
})
const pinia = createTestingPinia({
@@ -281,75 +276,4 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,7 +27,6 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -78,8 +77,6 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -91,7 +88,6 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -107,13 +103,6 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -138,6 +127,7 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -231,48 +230,6 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -142,9 +142,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters, defaultRootFilter = null } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -195,12 +194,8 @@ function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
const rootFilter = ref<RootCategoryId | null>(
defaultRootFilter === RootCategory.Essentials &&
!nodeAvailability.value.essential
? null
: defaultRootFilter
)
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -150,7 +150,8 @@ const telemetry = useTelemetry()
function onLogoMenuClick(event: MouseEvent) {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_comfy_menu_opened'
button_id: 'sidebar_comfy_menu_opened',
element_group: 'sidebar'
})
menuRef.value?.toggle(event)
}
@@ -217,7 +218,8 @@ const extraMenuItems = computed(() => [
icon: 'icon-[lucide--settings]',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened'
button_id: 'sidebar_settings_menu_opened',
element_group: 'sidebar'
})
showSettings()
}
@@ -329,7 +331,8 @@ const handleNodes2ToggleClick = () => {
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`,
element_group: 'sidebar'
})
}
</script>

View File

@@ -138,19 +138,23 @@ const onTabClick = async (item: SidebarTabExtension) => {
if (isNodeLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_node_library_selected'
button_id: 'sidebar_tab_node_library_selected',
element_group: 'sidebar'
})
else if (isModelLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_model_library_selected'
button_id: 'sidebar_tab_model_library_selected',
element_group: 'sidebar'
})
else if (isWorkflowsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_workflows_selected'
button_id: 'sidebar_tab_workflows_selected',
element_group: 'sidebar'
})
else if (isAssetsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_assets_media_selected'
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
})
await commandStore.commands

View File

@@ -21,7 +21,8 @@ const bottomPanelStore = useBottomPanelStore()
*/
const toggleConsole = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_bottom_panel_console_toggled'
button_id: 'sidebar_bottom_panel_console_toggled',
element_group: 'sidebar'
})
bottomPanelStore.toggleBottomPanel()
}

View File

@@ -30,7 +30,8 @@ const tooltipText = computed(
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked'
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
}
</script>

View File

@@ -37,7 +37,8 @@ const tooltipText = computed(
*/
const toggleShortcutsPanel = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_shortcuts_panel_toggled'
button_id: 'sidebar_shortcuts_panel_toggled',
element_group: 'sidebar'
})
bottomPanelStore.togglePanel('shortcuts')
}

View File

@@ -29,7 +29,8 @@ const isSmall = computed(
*/
const openTemplates = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_templates_dialog_opened'
button_id: 'sidebar_templates_dialog_opened',
element_group: 'sidebar'
})
useWorkflowTemplateSelectorDialog().show('sidebar')
}

View File

@@ -118,7 +118,8 @@ const toggleBookmark = async () => {
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button'
button_id: 'node_library_help_button',
element_group: 'node_library'
})
props.openNodeHelp(nodeDef.value)
}

View File

@@ -2,6 +2,9 @@ import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
@@ -16,7 +19,9 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601 */
renewalDate: string | null
/** ISO 8601 */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -44,6 +49,9 @@ export interface BillingActions {
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
resubscribe: () => Promise<void>
/** `amountCents` must be a whole-dollar multiple of 100. */
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
@@ -65,16 +73,12 @@ export interface BillingState {
currentPlanSlug: ComputedRef<string | null>
isLoading: Ref<boolean>
error: Ref<string | null>
/**
* Convenience computed for checking if subscription is active.
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
/**
* Whether the current billing context has a FREE tier subscription.
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
*/
isFreeTier: ComputedRef<boolean>
billingStatus: ComputedRef<BillingStatus | null>
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -5,13 +5,17 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
const {
mockTeamWorkspacesEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn()
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
@@ -50,8 +54,9 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
subscriptionStatus: {
value: { renewal_date: '2025-01-01T00:00:00Z', end_date: null }
},
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
@@ -70,6 +75,12 @@ vi.mock(
})
)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
purchaseCredits: mockPurchaseCredits
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
balance: { amount_micros: 5000000 },
@@ -129,7 +140,7 @@ describe('useBillingContext', () => {
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
renewalDate: '2025-01-01T00:00:00Z',
endDate: null,
isCancelled: false,
hasFunds: true
@@ -173,6 +184,13 @@ describe('useBillingContext', () => {
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('converts topup cents to whole dollars for the legacy credit endpoint', async () => {
const { topup } = useBillingContext()
await topup(500)
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)

View File

@@ -122,6 +122,15 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
const subscriptionStatus = computed(() =>
toValue(activeContext.value.subscriptionStatus)
)
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -218,6 +227,14 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.cancelSubscription()
}
async function resubscribe() {
return activeContext.value.resubscribe()
}
async function topup(amountCents: number) {
return activeContext.value.topup(amountCents)
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
@@ -241,6 +258,10 @@ function useBillingContextInternal(): BillingContext {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
getMaxSeats,
initialize,
@@ -250,6 +271,8 @@ function useBillingContextInternal(): BillingContext {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,7 +1,10 @@
import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
@@ -24,8 +27,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
formattedRenewalDate,
formattedEndDate,
subscriptionStatus: legacySubscriptionStatus,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
@@ -34,6 +36,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
} = useSubscription()
const authStore = useAuthStore()
const authActions = useAuthActions()
const isInitialized = ref(false)
const isLoading = ref(false)
@@ -52,8 +55,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
renewalDate: legacySubscriptionStatus.value?.renewal_date ?? null,
endDate: legacySubscriptionStatus.value?.end_date ?? null,
isCancelled: isCancelled.value,
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
}
@@ -75,6 +78,18 @@ export function useLegacyBilling(): BillingState & BillingActions {
}
})
// Legacy has no coarse billing_status concept (workspace-only).
const billingStatus = computed<BillingStatus | null>(() => null)
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
if (isCancelled.value) return 'canceled'
if (legacyIsActiveSubscription.value) return 'active'
return null
})
const tier = computed(() => subscriptionTier.value)
const renewalDate = computed(
() => legacySubscriptionStatus.value?.renewal_date ?? null
)
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
@@ -152,6 +167,16 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacyManageSubscription()
}
async function resubscribe(): Promise<void> {
// Legacy has no resubscribe endpoint; resubscribing is a fresh checkout.
await legacySubscribe()
}
async function topup(amountCents: number): Promise<void> {
// Facade standardizes on cents; legacy /customers/credit takes dollars.
await authActions.purchaseCredits(amountCents / 100)
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
@@ -179,6 +204,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
// Actions
initialize,
@@ -188,6 +217,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,8 +1,10 @@
import { uniq } from 'es-toolkit'
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { collectFromNodes } from '@/utils/graphTraversalUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
/**
* Composable for handling selected LiteGraph items filtering and operations.
@@ -71,7 +73,13 @@ export function useSelectedLiteGraphItems() {
* the prior null-tolerance for callers wired to early-firing commands.
*/
const getSelectedNodesShallow = (): LGraphNode[] =>
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
uniq(
[...(canvasStore.canvas?.selectedItems ?? [])].flatMap((item) => {
if (isLGraphNode(item)) return [item]
if (isLGraphGroup(item)) return [...item.children].filter(isLGraphNode)
return []
})
)
/**
* Get only the selected nodes (LGraphNode instances) from the canvas.

View File

@@ -4,8 +4,12 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointer,
CanvasPointerEvent,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -252,6 +256,273 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).toBeNull()
expect(mediaStore.missingMediaCandidates).toBeNull()
})
it('uses interior node execution ID for promoted widget error clearing', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'model.safetensors',
() => undefined,
{ values: ['model.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'other_model.safetensors',
'model.safetensors',
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears range errors for promoted widgets by interior widget name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps_input', type: 'INT' }]
})
const interiorNode = new LGraphNode('KSampler')
const interiorInput = interiorNode.addInput('steps_input', 'INT')
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
min: 1,
max: 100
})
interiorInput.widget = { name: 'steps' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
)
expect(promotedWidget).toBeDefined()
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'steps',
50,
150,
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.type = 'CheckpointLoaderSimple'
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
id: 65,
pos: [0, 0],
size: [200, 100]
})
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const missingModelStore = useMissingModelStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
missingModelStore.setMissingModels([
{
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
const promotedWidget = subgraphNode.widgets?.find(
(widget) =>
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
canvasX: 190,
canvasY: 20,
deltaX: 0
})
const pointer = fromAny<CanvasPointer, unknown>({
eDown: clickEvent
})
const canvas = fromAny<LGraphCanvas, unknown>({
graph_mouse: [190, 20],
last_mouseclick: 0
})
const handled = promotedWidget!.onPointerDown?.(
pointer,
subgraphNode,
canvas
)
expect(handled).toBe(true)
expect(pointer.onClick).toBeDefined()
pointer.onClick?.(clickEvent)
expect(missingModelStore.missingModelCandidates).toBeNull()
})
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first_ckpt', type: '*' },
{ name: 'second_ckpt', type: '*' }
]
})
const firstNode = new LGraphNode('CheckpointLoaderSimple')
firstNode.type = 'CheckpointLoaderSimple'
const firstInput = firstNode.addInput('first_ckpt', '*')
const firstWidget = firstNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
firstInput.widget = { name: 'ckpt_name' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const secondNode = new LGraphNode('CheckpointLoaderSimple')
secondNode.type = 'CheckpointLoaderSimple'
const secondInput = secondNode.addInput('second_ckpt', '*')
secondNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
secondInput.widget = { name: 'ckpt_name' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const promotedWidgets =
subgraphNode.widgets?.filter(
(widget) =>
'sourceWidgetName' in widget &&
widget.sourceWidgetName === 'ckpt_name'
) ?? []
expect(promotedWidgets).toHaveLength(2)
const missingModelStore = useMissingModelStore()
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
missingModelStore.setMissingModels([
{
nodeId: firstExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate,
{
nodeId: secondExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
firstWidget.value = 'present.safetensors'
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'present.safetensors',
'missing.safetensors',
firstWidget
)
expect(missingModelStore.missingModelCandidates).toEqual([
expect.objectContaining({
nodeId: secondExecId,
widgetName: 'ckpt_name',
name: 'missing.safetensors'
})
])
})
})
describe('installErrorClearingHooks lifecycle', () => {
@@ -978,54 +1249,4 @@ describe('clearWidgetRelatedErrors parameter routing', () => {
clearSpy.mockRestore()
})
it('clears promoted widget errors by interior execution id', () => {
const subgraph = createTestSubgraph()
const graph = subgraph.rootGraph
const host = createTestSubgraphNode(subgraph, { id: 2 })
graph.add(host)
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.id = 1
subgraph.add(interiorNode)
const input = interiorNode.addInput('ckpt_name', 'COMBO')
const widget = interiorNode.addWidget(
'combo',
'ckpt_name',
'fake_model.safetensors',
() => undefined,
{ values: ['fake_model.safetensors', 'real_model.safetensors'] }
)
input.widget = { name: widget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, widget).ok
).toBe(true)
installErrorClearingHooks(graph)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const missingModelStore = useMissingModelStore()
missingModelStore.setMissingModels([
{
nodeId: '2:1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'fake_model.safetensors',
directory: 'checkpoints',
isMissing: true
}
])
const promotedWidget = host.widgets[0]
host.onWidgetChanged!.call(
host,
promotedWidget.name,
'real_model.safetensors',
'fake_model.safetensors',
promotedWidget
)
expect(missingModelStore.hasMissingModels).toBe(false)
})
})

View File

@@ -6,9 +6,12 @@
* works in legacy canvas mode as well.
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import {
LGraphEventMode,
NodeSlotType
@@ -43,6 +46,130 @@ import {
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
interface WidgetErrorClearingTarget {
executionId: string
validationInputName: string
assetWidgetName: string
currentValue: unknown
options?: { min?: number; max?: number }
}
function getWidgetRangeOptions(widget: IBaseWidget): {
min?: number
max?: number
} {
return {
min: widget.options?.min,
max: widget.options?.max
}
}
function plainWidgetToErrorTarget(
widget: IBaseWidget,
hostExecId: string
): WidgetErrorClearingTarget {
return {
executionId: hostExecId,
validationInputName: widget.name,
assetWidgetName: widget.name,
currentValue: widget.value,
options: getWidgetRangeOptions(widget)
}
}
function promotedWidgetToErrorTarget(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: PromotedWidgetView,
hostExecId: string
): WidgetErrorClearingTarget {
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
const execId =
result.status === 'resolved' && result.resolved.node
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
: hostExecId
const resolvedWidget =
result.status === 'resolved' ? result.resolved.widget : widget
return {
executionId: execId,
validationInputName: resolvedWidget.name,
assetWidgetName: widget.sourceWidgetName,
currentValue: resolvedWidget.value,
options: getWidgetRangeOptions(resolvedWidget)
}
}
function resolveCanvasPathPromotedWidgetTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
// Canvas-path events lose promoted identity, so the post-write value
// disambiguates same-named promoted widgets.
return (hostNode.widgets ?? [])
.filter(isPromotedWidgetView)
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
.map((promotedWidget) =>
promotedWidgetToErrorTarget(
rootGraph,
hostNode,
promotedWidget,
hostExecId
)
)
.filter((target) => Object.is(target.currentValue, newValue))
}
function resolveWidgetErrorTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (isPromotedWidgetView(widget)) {
return [
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
]
}
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
rootGraph,
hostNode,
widget,
hostExecId,
newValue
)
return canvasPathTargets.length
? canvasPathTargets
: [plainWidgetToErrorTarget(widget, hostExecId)]
}
function clearWidgetErrorTargets(
targets: WidgetErrorClearingTarget[],
newValue: unknown
): void {
const store = useExecutionErrorStore()
for (const target of targets) {
store.clearWidgetRelatedErrors(
target.executionId,
target.validationInputName,
target.assetWidgetName,
newValue,
target.options
)
}
}
const hookedNodes = new WeakSet<LGraphNode>()
type OriginalCallbacks = {
@@ -76,24 +203,21 @@ function installNodeHooks(node: LGraphNode): void {
node.onWidgetChanged = useChainCallback(
node.onWidgetChanged,
// _name is the LiteGraph callback arg; re-derive from the widget
// object to handle promoted widgets where sourceWidgetName differs.
function (_name, newValue, _oldValue, widget) {
if (!app.rootGraph) return
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const promotedSource = widgetPromotedSource(node, widget)
const executionId = promotedSource
? `${hostExecId}:${promotedSource.nodeId}`
: hostExecId
const widgetName = promotedSource?.widgetName ?? widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
executionId,
widgetName,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
const targets = resolveWidgetErrorTargets(
app.rootGraph,
node,
widget,
hostExecId,
newValue
)
clearWidgetErrorTargets(targets, newValue)
}
)
}

View File

@@ -1,11 +1,13 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import { widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -45,10 +47,9 @@ describe('Node Reactivity', () => {
expect((widget as BaseWidget).node.id).toBe(node.id)
// Initial value should be in store after setNodeId was called
const id = widgetId(graph.id, node.id, 'testnum')
expect(store.getWidget(id)?.value).toBe(2)
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
const state = store.getWidget(id)
const state = store.getWidget(graph.id, node.id, 'testnum')
if (!state) throw new Error('Expected widget state to exist')
const onValueChange = vi.fn()
@@ -73,7 +74,7 @@ describe('Node Reactivity', () => {
})
await nextTick()
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
const state = store.getWidget(graph.id, node.id, 'testnum')
if (!state) throw new Error('Expected widget state to exist')
const widgetValue = computed(() => state.value)
@@ -210,32 +211,105 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
const interiorInput = interiorNode.addInput('value', 'STRING')
interiorNode.id = 10
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
interiorInput.widget = { name: 'prompt' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
'value',
'value'
)
// Host the promoted view on a regular node so we can control widgets
// directly (SubgraphNode.widgets is a synthetic getter).
const graph = new LGraph()
const hostNode = new LGraphNode('host')
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(hostNode.id))
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
// input slot widget name is "value" — slotName bridges this gap.
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata).toBeDefined()
})
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')
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).sourceNodeId = '9999'
fromAny<
{
sourceNodeId: string
sourceWidgetName: string
},
unknown
>(secondPromotedView).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')
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(secondMappedWidget.name).not.toBe('stale_widget')
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
@@ -374,8 +448,8 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
expect(mappedWidget?.entityId).toBe(
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
)
})
@@ -410,13 +484,13 @@ describe('Nested promoted widget mapping', () => {
const widgets = nodeData?.widgets
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
expect(widgets?.[0]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'second_seed')
expect(widgets?.[1]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
})
})
@@ -454,11 +528,10 @@ describe('Promoted widget sourceExecutionId', () => {
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
(w) => w.name === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(

View File

@@ -3,16 +3,17 @@
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import {
inputForWidget,
promotedInputSource,
promotedInputWidgets
} from '@/core/graph/subgraph/promotedInputWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
resolveConcretePromotedWidget,
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -26,11 +27,10 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { WidgetId } from '@/types/widgetId'
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import type {
LGraph,
@@ -38,8 +38,7 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam,
SubgraphNode
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
@@ -61,7 +60,7 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
entityId?: WidgetEntityId
nodeId?: NodeId
name: string
type: string
@@ -82,12 +81,17 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Original LiteGraph widget name used for slot metadata matching.
* For promoted widgets, `name` is `sourceWidgetName` (interior widget name)
* which differs from the subgraph node's input slot widget name.
*/
slotName?: string
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the
@@ -95,14 +99,10 @@ export interface SafeWidgetData {
* execution ID (e.g. `"65:42"` vs the host node's `"65"`).
*/
sourceExecutionId?: string
/**
* Interior source widget name. Only set for promoted widgets, where `name`
* is the host input slot name; missing-model lookups key by the interior
* widget name, which can differ from the slot name (e.g. after a rename).
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
/** For promoted widgets, the display label from the subgraph input slot. */
promotedLabel?: string
}
export interface VueNodeData {
@@ -143,6 +143,18 @@ export interface GraphNodeManager {
cleanup(): void
}
function isPromotedDOMWidget(widget: IBaseWidget): boolean {
if (!isPromotedWidgetView(widget)) return false
const sourceWidget = resolvePromotedWidgetSource(widget.node, widget)
if (!sourceWidget) return false
const innerWidget = sourceWidget.widget
return (
('element' in innerWidget && !!innerWidget.element) ||
('component' in innerWidget && !!innerWidget.component)
)
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
@@ -202,83 +214,73 @@ function normalizeWidgetValue(value: unknown): WidgetValue {
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: string
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const input = inputForWidget(node, widget)
if (!input?.widgetId) return undefined
const source = promotedInputSource(node, input)
if (!source) return undefined
const resolution = resolveConcretePromotedWidget(
node,
source.nodeId,
source.widgetName
)
const resolved =
resolution.status === 'resolved' ? resolution.resolved : undefined
const sourceWidget = resolved?.widget
const sourceNode = resolved?.node
ensurePromotedHostWidgetState(input.widgetId, input, sourceWidget)
return {
controlWidget: sourceWidget ? getControlWidget(sourceWidget) : undefined,
isDOMWidget: sourceWidget ? isDOMBackedWidget(sourceWidget) : false,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
sourceWidgetName: sourceWidget?.name
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
displayName: widget.name,
promotedSource: null
}
}
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource: PromotedWidgetSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
const promotedSource =
matchedInput?._widget === widget
? (resolvePromotedSourceByInputName(displayName) ?? directSource)
: directSource
return {
displayName,
promotedSource
}
}
return function (widget) {
try {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
const { displayName, promotedSource } =
resolvePromotedWidgetIdentity(widget)
// Get shared enhancements (controlWidget, spec, nodeType)
const sharedEnhancements = getSharedWidgetEnhancements(node, widget)
const slotInfo =
slotMetadata.get(displayName) ?? slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
@@ -292,26 +294,67 @@ function safeWidgetMapper(
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
const isPromotedPseudoWidget =
isPromotedWidgetView(widget) && widget.sourceWidgetName.startsWith('$$')
// Extract only render-critical options (canvasOnly, advanced, read_only)
const options = extractWidgetDisplayOptions(widget)
const subgraphId = node.isSubgraphNode() && node.subgraph.id
const resolvedSourceResult =
isPromotedWidgetView(widget) && promotedSource
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
)
: null
const resolvedSource =
resolvedSourceResult?.status === 'resolved'
? resolvedSourceResult.resolved
: undefined
const sourceWidget = resolvedSource?.widget
const sourceNode = resolvedSource?.node
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined
const sourceWidgetName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = sourceWidgetName ?? displayName
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
entityId: getWidgetEntityIdForNode(node, widget),
nodeId,
name,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
hasLayoutSize: typeof effectiveWidget.computeLayoutSize === 'function',
isDOMWidget: isDOMWidget(widget) || isPromotedDOMWidget(widget),
options: isPromotedPseudoWidget
? {
...(extractWidgetDisplayOptions(effectiveWidget) ?? options),
canvasOnly: true
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
// For promoted widgets, name is sourceWidgetName while widget.name
// is the subgraph input slot name — store the slot name for lookups.
slotName: name !== widget.name ? widget.name : undefined,
sourceExecutionId:
sourceNode && app.rootGraph
? (getExecutionIdByNode(app.rootGraph, sourceNode) ?? undefined)
: undefined,
tooltip: widget.tooltip,
promotedLabel: isPromotedWidgetView(widget) ? widget.label : undefined
}
} catch (error) {
console.warn(
@@ -327,24 +370,6 @@ function safeWidgetMapper(
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
@@ -446,16 +471,14 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const widgetsSnapshot = node.widgets ?? []
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
return widgetsSnapshot.map(safeWidgetMapper(node, slotMetadata))
})
const nodeType =
@@ -511,7 +534,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
widget.slotMetadata = slotMetadata.get(widget.slotName ?? widget.name)
}
}
@@ -789,7 +812,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
// Re-extract widget data so promotedLabel reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}

View File

@@ -1,6 +1,7 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type {
LGraphGroup,
LGraphNode,
@@ -264,8 +265,16 @@ export function useMoreOptionsMenu() {
options.push(...getImageMenuOptions(selectedNodes.value[0]))
options.push({ type: 'divider' })
}
const [widgetName] = hoveredWidget.value ?? []
const widget = node?.widgets?.find((w) => w.name === widgetName)
const [widgetName, nodeId] = hoveredWidget.value ?? []
const widget =
nodeId !== undefined
? node?.widgets?.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceWidgetName === widgetName &&
w.sourceNodeId === nodeId
)
: node?.widgets?.find((w) => w.name === widgetName)
if (widget) {
const widgetOptions = convertContextMenuToOptions(
getExtraOptionsForWidget(node, widget)

View File

@@ -7,7 +7,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
@@ -41,6 +46,11 @@ export function useSelectionState() {
const hasAnySelection = computed(() => selectedItems.value.length > 0)
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
const hasGroupedNodesSelection = computed(() =>
selectedItems.value.some(
(item) => isLGraphGroup(item) && [...item.children].some(isLGraphNode)
)
)
const isSingleNode = computed(
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
@@ -112,6 +122,7 @@ export function useSelectionState() {
openNodeInfo,
hasAny3DNodeSelected,
hasAnySelection,
hasGroupedNodesSelection,
hasSingleSelection,
hasMultipleSelection,
isSingleNode,

View File

@@ -1,21 +1,11 @@
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
const getNodeDisplayPrice = vi.fn(
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
String(overrides?.get('prompt') ?? 'missing override')
)
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({ getNodeDisplayPrice })
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
@@ -64,43 +54,4 @@ describe('subgraph pricing', () => {
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
}
)
subgraphTest(
'uses promoted widget override from any matching internal link',
({ subgraphWithNode }) => {
const { subgraphNode, subgraph } = subgraphWithNode
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
const apiNode = new ApiNode('api node')
apiNode.badges = [getCreditsBadge('$0.05/Run')]
const apiInput = apiNode.addInput('prompt', 'STRING')
apiInput.widget = { name: 'prompt' }
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
const decoyNode = new LGraphNode('decoy node')
const decoyInput = decoyNode.addInput('prompt', 'STRING')
decoyInput.widget = { name: 'prompt' }
decoyNode.addWidget(
'string',
'prompt',
'decoy value',
() => undefined,
{}
)
subgraph.add(decoyNode)
subgraph.add(apiNode)
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
subgraphNode._internalConfigureAfterSlots()
const inputWidgetId = subgraphNode.inputs[0].widgetId
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
updateSubgraphCredits(subgraphNode)
expect(getBadgeText(subgraphNode)).toBe('outer value')
}
)
})

View File

@@ -2,14 +2,9 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: { linkIds?: number[] }
}
const componentIconSvg = new Image()
componentIconSvg.src =
@@ -100,20 +95,11 @@ export const usePriceBadge = () => {
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
if (!input.widgetId) continue
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
const link = wrapper.subgraph.getLink(linkId)
if (link?.target_id !== innerNode.id) continue
const targetInput = innerNode.inputs[link.target_slot]
const widgetName = targetInput?.widget?.name
if (!widgetName) continue
overrides.set(
widgetName,
useWidgetValueStore().getWidget(input.widgetId)?.value
)
}
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
}
return overrides
}

View File

@@ -9,16 +9,26 @@ export type AppMode =
| 'builder:outputs'
| 'builder:arrange'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined
}
export function getWorkflowMode(
workflow: WorkflowModeSource | null | undefined
): AppMode {
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
}
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow))
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
@@ -29,9 +39,7 @@ export function useAppMode() {
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isAppMode = computed(() => isAppModeValue(mode.value))
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)

View File

@@ -38,7 +38,8 @@ export function useHelpCenter() {
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled'
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
})
helpCenterStore.toggle()
}

View File

@@ -1,19 +1,12 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { WidgetState } from '@/types/widgetState'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
function widget(name: string, value: unknown): WidgetState {
return {
name,
type: 'INPUT',
value,
nodeId: '1' as NodeId,
options: {},
y: 0
}
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
}
const isNumber = (v: unknown): v is number => typeof v === 'number'

View File

@@ -2,9 +2,9 @@ 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'
import type { WidgetState } from '@/types/widgetState'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -20,8 +21,8 @@ import {
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
@@ -54,15 +55,39 @@ function addInnerNode(
return node
}
function getPromotedInputValue(
function addPromotedHostInput(
host: SubgraphNode,
name: string
): TWidgetValue | undefined {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) return undefined
return useWidgetValueStore().getWidget(input.widgetId)?.value as
| TWidgetValue
| undefined
args: {
inputName: string
promotedName: string
sourceNodeId: string
sourceWidgetName: string
initialValue?: TWidgetValue
}
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
let widgetValue: TWidgetValue = args.initialValue ?? 0
const slot = host.addInput(args.inputName, '*')
slot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: args.promotedName,
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
},
hydrateHostValue(v: TWidgetValue) {
widgetValue = v
}
})
return {
setValue: (v) => {
widgetValue = v
},
getValue: () => widgetValue
}
}
function addPrimitiveWithTargets(
@@ -116,6 +141,29 @@ describe('flushProxyWidgetMigration', () => {
})
describe('value-widget repair', () => {
it('alreadyLinked: applies host value to the matching promoted widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 0
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(handle.getValue()).toBe(99)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
@@ -135,61 +183,23 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [99]
})
expect(getPromotedInputValue(host, 'seed')).toBe(99)
expect(host.widgets[0].value).toBe(99)
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
expect(innerWidget.value).toBe(0)
})
it('createSubgraphInput: uses disambiguator for duplicate nested widget names', () => {
const rootGraph = new LGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const firstText = new LGraphNode('CLIPTextEncode')
const firstSlot = firstText.addInput('text', 'STRING')
firstSlot.widget = { name: 'text' }
firstText.addWidget('text', 'text', '11111111111', () => {})
innerSubgraph.add(firstText)
const secondText = new LGraphNode('CLIPTextEncode')
const secondSlot = secondText.addInput('text', 'STRING')
secondSlot.widget = { name: 'text' }
secondText.addWidget('text', 'text', '22222222222', () => {})
innerSubgraph.add(secondText)
const nestedHost = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
nestedHost.properties.proxyWidgets = [
[String(firstText.id), 'text'],
[String(secondText.id), 'text']
]
flushProxyWidgetMigration({ hostNode: nestedHost })
const outerSubgraph = createTestSubgraph({ rootGraph })
outerSubgraph.add(nestedHost)
const outerHost = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
outerHost.properties.proxyWidgets = [
[String(nestedHost.id), 'text', String(secondText.id)]
]
flushProxyWidgetMigration({ hostNode: outerHost })
expect(getPromotedInputValue(outerHost, 'text')).toBe('22222222222')
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
const innerWidget = n.addWidget('number', 'seed', 7, () => {})
slot.widget = { name: innerWidget.name }
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 7
})
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
const sparse: unknown[] = []
@@ -198,7 +208,43 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: sparse
})
expect(getPromotedInputValue(host, 'seed')).toBe(7)
expect(handle.getValue()).toBe(7)
})
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const a = addPromotedHostInput(host, {
inputName: 'first_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 1
})
const b = addPromotedHostInput(host, {
inputName: 'second_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 2
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(a.getValue()).toBe(1)
expect(b.getValue()).toBe(2)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'ambiguousSubgraphInput'
})
])
})
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
@@ -218,25 +264,29 @@ describe('flushProxyWidgetMigration', () => {
expect(created?._widget).toBeDefined()
})
it('createSubgraphInput: preserves the source slot label', () => {
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('text', 'STRING')
slot.label = 'renamed_from_sidepanel'
slot.widget = { name: 'text' }
n.addWidget('text', 'text', '', () => {})
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
const slot1 = n.addInput('text', 'STRING')
slot1.widget = { name: 'text' }
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
const slot2 = n.addInput('text_1', 'STRING')
slot2.widget = { name: 'text_1' }
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
})
host.properties.proxyWidgets = [[String(inner.id), 'text']]
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
flushProxyWidgetMigration({ hostNode: host })
const promotedInput = host.inputs.find((input) => input.name === 'text')
expect(promotedInput?.label).toBe('renamed_from_sidepanel')
expect(
promotedInput?.widgetId
? useWidgetValueStore().getWidget(promotedInput.widgetId)?.label
: undefined
).toBe('renamed_from_sidepanel')
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
const linkedSlot = inner.inputs.find(
(slot) => slot.link === created?.linkIds[0]
)
expect(linkedSlot?.name).toBe('text_1')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
@@ -311,7 +361,8 @@ describe('flushProxyWidgetMigration', () => {
hostWidgetValues: [123]
})
expect(getPromotedInputValue(host, 'value')).toBe(123)
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(123)
})
it('seeds value from the primitive widget when no host value is supplied', () => {
@@ -324,7 +375,8 @@ describe('flushProxyWidgetMigration', () => {
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(getPromotedInputValue(host, 'value')).toBe(11)
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(11)
})
it('quarantines an unlinked primitive node with no fan-out', () => {
@@ -422,8 +474,10 @@ describe('flushProxyWidgetMigration', () => {
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(getPromotedInputValue(hostA, 'value')).toBe(11)
expect(getPromotedInputValue(hostB, 'value')).toBe(22)
const widgetA = hostA.inputs.at(-1)?._widget
const widgetB = hostB.inputs.at(-1)?._widget
expect(widgetA?.value).toBe(11)
expect(widgetB?.value).toBe(22)
})
})

View File

@@ -1,6 +1,6 @@
import { isEqual } from 'es-toolkit/compat'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
findHostInputForPromotion,
@@ -8,7 +8,6 @@ import {
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
@@ -28,7 +27,6 @@ import type {
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
@@ -95,24 +93,23 @@ function resolveSourceWidget(
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): IBaseWidget | undefined {
if (sourceNode.isSubgraphNode()) {
const input = sourceNode.inputs.find((input) => {
const target = resolveSubgraphInputTarget(sourceNode, input.name)
if (disambiguatingSourceNodeId) {
return (
target?.widgetName === sourceWidgetName &&
target.nodeId === disambiguatingSourceNodeId
)
}
if (input.name === sourceWidgetName) return true
return target?.widgetName === sourceWidgetName
})
// Store-backed projection for a promoted input on a nested subgraph node:
// getSlotFromWidget locates the backing slot by widgetId.
if (input?.widgetId) return promotedInputWidget(input) ?? undefined
const widgets = sourceNode.widgets
if (widgets && disambiguatingSourceNodeId !== undefined) {
const byDisambiguator = widgets.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceNodeId === disambiguatingSourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
if (byDisambiguator) return byDisambiguator
// Disambiguator missed: fall back only to non-promoted same-name widgets.
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
const byName = widgets.find(
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
)
if (byName) return byName
}
const widgets = sourceNode.widgets
return (
widgets?.find((w) => w.name === sourceWidgetName) ??
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
@@ -303,6 +300,19 @@ function classify(
normalized.sourceWidgetName
)
if (linkedInput) {
const ambiguous =
hostNode.inputs.filter((input) => {
const w = input._widget
return (
!!w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === normalized.sourceNodeId &&
w.sourceWidgetName === normalized.sourceWidgetName
)
}).length > 1
if (ambiguous) {
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
@@ -363,23 +373,19 @@ function classify(
}
}
function applyHostValueToInput(
input: INodeInputSlot,
entry: PendingEntry
): boolean {
if (!input.widgetId || entry.isHole) return Boolean(input.widgetId)
return useWidgetValueStore().setValue(input.widgetId, entry.hostValue)
}
function applyHostLabelToInput(
input: INodeInputSlot,
label: string | undefined
): void {
if (label === undefined) return
input.label = label
if (!input.widgetId) return
const state = useWidgetValueStore().getWidget(input.widgetId)
if (state) state.label = label
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
if (entry.isHole) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(entry.hostValue)
return
}
console.error(
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
{ widgetName: widget.name, type: widget.type }
)
}
function addUniqueSubgraphInput(
@@ -416,9 +422,10 @@ function repairAlreadyLinked(
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
const hostInput = matches[0]
if (!applyHostValueToInput(hostInput, entry)) {
if (!hostInput._widget) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: hostInput.name }
}
@@ -473,10 +480,11 @@ function repairCreateSubgraphInput(
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (hostInput) {
applyHostLabelToInput(hostInput, slot.label)
applyHostValueToInput(hostInput, entry)
if (!hostInput?._widget) {
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
@@ -641,19 +649,22 @@ function repairPrimitive(
return failPrimitive('mutation failed; rolled back', { error: e })
}
// Apply through the host's input mirror (PromotedWidgetView), NOT
// `newSubgraphInput._widget`: the interior is shared across hosts.
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (hostInput) {
const hostInputWidget = hostInput?._widget
if (hostInputWidget) {
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
if (valueEntry) {
applyHostValueToInput(hostInput, valueEntry)
applyHostValue(hostInputWidget, valueEntry)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined) {
applyHostValueToInput(hostInput, {
applyHostValue(hostInputWidget, {
...validated.uniqueEntries[0],
hostValue: primitiveValue,
isHole: false

View File

@@ -1,118 +0,0 @@
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { resolveSubgraphInputTarget } from './resolveSubgraphInputTarget'
/**
* Where a promoted subgraph input is sourced from inside the subgraph. The
* interior node id + widget name that the host input slot forwards to. Resolved
* by walking the live link, so it is authoritative derived data — never stored
* on the projected widget.
*/
export interface PromotedSource {
nodeId: string
widgetName: string
}
/**
* The interior source of a host input slot, or undefined when the slot is not a
* promoted widget input.
*/
export function promotedInputSource(
node: LGraphNode,
input: INodeInputSlot
): PromotedSource | undefined {
if (!input.widgetId) return undefined
return resolveSubgraphInputTarget(node, input.name)
}
/** The host input slot backing a projected widget, matched by widgetId. */
export function inputForWidget(
node: LGraphNode,
widget: IBaseWidget
): INodeInputSlot | undefined {
return node.getSlotFromWidget(widget)
}
/**
* The interior source of a widget when it is a promoted subgraph input.
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
* host node is a subgraph node and its backing input slot has an interior
* source.
*/
export function widgetPromotedSource(
node: LGraphNode,
widget: IBaseWidget
): PromotedSource | undefined {
if (!node.isSubgraphNode()) return undefined
const input = inputForWidget(node, widget)
if (!input) return undefined
return promotedInputSource(node, input)
}
/**
* Projects a promoted subgraph input into an ordinary widget descriptor. The
* descriptor is store-backed: type/value/options read live from
* {@link useWidgetValueStore} by widgetId (mirroring BaseWidget), so the row
* list does not reactively rebuild — and re-key — on every value edit.
*
* `name` is the input slot name (unique + fixed; widgetId derives from it), and
* `label` is the mutable display label. Returns null when the input is not a
* promoted widget input.
*/
export function promotedInputWidget(input: INodeInputSlot): IBaseWidget | null {
const id = input.widgetId
if (!id) return null
const store = useWidgetValueStore()
return {
get name() {
return store.getWidget(id)?.name ?? input.name
},
get label() {
return store.getWidget(id)?.label ?? input.label ?? input.name
},
set label(next) {
const state = store.getWidget(id)
if (state) state.label = next
},
get y() {
return store.getWidget(id)?.y ?? 0
},
set y(next) {
const state = store.getWidget(id)
if (state) state.y = next
},
widgetId: id,
get type() {
return store.getWidget(id)?.type ?? 'text'
},
get options() {
return store.getWidget(id)?.options ?? {}
},
get value() {
const value = store.getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
},
set value(next) {
store.setValue(id, next)
},
// Canvas edits operate on a transient concrete widget (toConcreteWidget),
// so the value setter above is never invoked; BaseWidget.setValue writes its
// own local state and then calls this callback, which is the only bridge
// back to the store.
callback(next) {
store.setValue(id, next)
}
}
}
/** Every promoted subgraph input on a node, projected to ordinary widgets. */
export function promotedInputWidgets(node: LGraphNode): IBaseWidget[] {
return node.inputs.flatMap((input) => {
const widget = promotedInputWidget(input)
return widget ? [widget] : []
})
}

View File

@@ -1,17 +1,31 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { WidgetEntityId } from '@/world/entityIds'
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
/**
* A persisted promotion's source identity: the interior node + widget a host
* subgraph input was promoted from. Used by the migration/schema layer, where
* the source is a stored tuple rather than something link-derivable.
*/
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly entityId: WidgetEntityId
readonly sourceNodeId: string
readonly sourceWidgetName: string
hydrateHostValue(value: IBaseWidget['value']): void
ensureHostWidgetState(): void
}
export function isPromotedWidgetView(
widget: IBaseWidget
): widget is PromotedWidgetView {
return 'sourceNodeId' in widget && 'sourceWidgetName' in widget
}

View File

@@ -0,0 +1,100 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { isPromotedWidgetView } from './promotedWidgetTypes'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNumericInteriorNode(initialValue: number) {
const node = new LGraphNode('Interior')
const input = node.addInput('value', 'number')
node.addOutput('out', 'number')
const widget = node.addWidget('number', 'widget', initialValue, () => {}, {
min: 0,
max: 100,
step: 1
})
input.widget = { name: widget.name }
return { node, widget }
}
describe('PromotedWidgetView — host-wins semantics', () => {
it('does not leak host-side writes into the interior widget or into a sibling host', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior, widget: interiorWidget } =
createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const hostA = createTestSubgraphNode(subgraph, { id: 100 })
const hostB = createTestSubgraphNode(subgraph, { id: 101 })
const viewA = hostA.widgets.find(isPromotedWidgetView)
const viewB = hostB.widgets.find(isPromotedWidgetView)
if (!viewA || !viewB)
throw new Error('Expected promoted views on both hosts')
viewA.value = 7
expect(viewA.value).toBe(7)
expect(interiorWidget.value).toBe(42)
expect(viewB.value).toBe(42)
})
it('keeps the interior widgetValueStore row untouched when a host writes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interior } = createNumericInteriorNode(42)
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(subgraph.rootGraph.id, {
nodeId: String(interior.id),
name: 'widget',
type: 'number',
value: 42,
options: {},
label: undefined,
serialize: true,
disabled: false
})
const host = createTestSubgraphNode(subgraph, { id: 200 })
const view = host.widgets.find(isPromotedWidgetView)
if (!view) throw new Error('Expected promoted view on host')
view.value = 99
const interiorState = widgetStore.getWidget(
subgraph.rootGraph.id,
String(interior.id),
'widget'
)
expect(interiorState?.value).toBe(42)
})
})

View File

@@ -0,0 +1,614 @@
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'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
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 type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
export type { PromotedWidgetView } from './promotedWidgetTypes'
export { isPromotedWidgetView } from './promotedWidgetTypes'
interface SubgraphSlotRef {
name: string
label?: string
displayName?: string
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
function hasLegacyMouse(widget: IBaseWidget): widget is LegacyMouseWidget {
return 'mouse' in widget && typeof widget.mouse === 'function'
}
const designTokenCache = new Map<string, string>()
export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
identityName
)
}
class PromotedWidgetView implements IPromotedWidgetView {
[symbol: symbol]: boolean
readonly sourceNodeId: string
readonly sourceWidgetName: string
readonly serialize = false
last_y?: number
computedHeight?: number
private readonly graphId: string
private yValue = 0
private _computedDisabled = false
private projectedSourceNode?: LGraphNode
private projectedSourceWidget?: IBaseWidget
private projectedSourceWidgetType?: IBaseWidget['type']
private projectedWidget?: BaseWidget
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
private _lastAutoSeededValue?: IBaseWidget['value']
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
this.graphId = subgraphNode.rootGraph.id
}
get node(): SubgraphNode {
return this.subgraphNode
}
get name(): string {
return this.identityName ?? this.sourceWidgetName
}
get entityId(): WidgetEntityId {
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
}
get y(): number {
return this.yValue
}
set y(value: number) {
this.yValue = value
this.syncDomOverride()
}
get computedDisabled(): boolean {
return this._computedDisabled
}
set computedDisabled(value: boolean | undefined) {
this._computedDisabled = value ?? false
}
get type(): IBaseWidget['type'] {
return this.resolveDeepest()?.widget.type ?? 'button'
}
get options(): IBaseWidget['options'] {
return this.resolveDeepest()?.widget.options ?? {}
}
get tooltip(): string | undefined {
return this.resolveDeepest()?.widget.tooltip
}
get linkedWidgets(): IBaseWidget[] | undefined {
return this.resolveDeepest()?.widget.linkedWidgets
}
get value(): IBaseWidget['value'] {
const hostState = this.getHostWidgetState()
if (hostState && isWidgetValue(hostState.value)) return hostState.value
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
this.setHostWidgetState(value)
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
}
private setHostWidgetState(value: IBaseWidget['value']): void {
if (!isWidgetValue(value)) return
const state = this.getHostWidgetState()
if (state) {
state.value = value
this._lastAutoSeededValue = undefined
return
}
this.registerHostWidgetState(value)
this._lastAutoSeededValue = undefined
}
ensureHostWidgetState(): void {
const fallback = this.fallbackEffectiveValue()
const existing = this.getHostWidgetState()
if (existing) {
if (
this._lastAutoSeededValue !== undefined &&
existing.value === this._lastAutoSeededValue &&
isWidgetValue(fallback) &&
fallback !== existing.value
) {
existing.value = fallback
this._lastAutoSeededValue = fallback
}
return
}
this.registerHostWidgetState(fallback)
this._lastAutoSeededValue = fallback
}
private fallbackEffectiveValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
label: this.displayName,
serialize: this.serialize,
disabled: this.computedDisabled
})
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
const state = this.getWidgetState()
return state?.label ?? this.displayName
}
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
const state = this.getWidgetState()
if (state) state.label = value
}
hydrateHostValue(value: IBaseWidget['value']): void {
this.setHostWidgetState(value)
}
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
this._boundSlot = this.findBoundSubgraphSlot()
this._boundSlotVersion = version
return this._boundSlot
}
private findBoundSubgraphSlot(): SubgraphSlotRef | undefined {
for (const input of this.subgraphNode.inputs ?? []) {
const slot = input._subgraphSlot as SubgraphSlotRef | undefined
if (!slot) continue
const w = input._widget
if (
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === this.sourceNodeId &&
w.sourceWidgetName === this.sourceWidgetName
) {
return slot
}
}
return undefined
}
get hidden(): boolean {
return this.resolveDeepest()?.widget.hidden ?? false
}
get computeLayoutSize(): IBaseWidget['computeLayoutSize'] {
const resolved = this.resolveDeepest()
const computeLayoutSize = resolved?.widget.computeLayoutSize
if (!computeLayoutSize) return undefined
return (node: LGraphNode) => computeLayoutSize.call(resolved.widget, node)
}
get computeSize(): IBaseWidget['computeSize'] {
const resolved = this.resolveDeepest()
const computeSize = resolved?.widget.computeSize
if (!computeSize) return undefined
return (width?: number) => computeSize.call(resolved.widget, width)
}
draw(
ctx: CanvasRenderingContext2D,
_node: LGraphNode,
widgetWidth: number,
y: number,
H: number,
lowQuality?: boolean
): void {
const resolved = this.resolveDeepest()
if (!resolved) {
drawDisconnectedPlaceholder(ctx, widgetWidth, y, H)
return
}
if (isBaseDOMWidget(resolved.widget)) return this.syncDomOverride(resolved)
const projected = this.getProjectedWidget(resolved)
if (!projected || typeof projected.drawWidget !== 'function') return
const originalY = projected.y
const originalComputedHeight = projected.computedHeight
const originalComputedDisabled = projected.computedDisabled
const originalLabel = projected.label
projected.y = this.y
projected.computedHeight = this.computedHeight
projected.computedDisabled = this.computedDisabled
projected.value = this.value
projected.label = this.label
try {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
previewImages: resolved.node.imgs
})
} finally {
projected.y = originalY
projected.computedHeight = originalComputedHeight
projected.computedDisabled = originalComputedDisabled
projected.label = originalLabel
}
}
onPointerDown(
pointer: CanvasPointer,
_node: LGraphNode,
canvas: LGraphCanvas
): boolean {
const resolved = this.resolveAtHost()
if (!resolved) return false
const interior = resolved.widget
if (typeof interior.onPointerDown === 'function') {
const handled = interior.onPointerDown(pointer, this.subgraphNode, canvas)
if (handled) return true
}
const concrete = toConcreteWidget(interior, this.subgraphNode, false)
if (concrete)
return this.bindConcretePointerHandlers(pointer, canvas, concrete)
if (hasLegacyMouse(interior))
return this.handleLegacyMouse(pointer, interior)
return false
}
callback(
value: unknown,
canvas?: LGraphCanvas,
node?: LGraphNode,
pos?: Point,
e?: CanvasPointerEvent
) {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
afterQueued({
isPartialExecution
}: { isPartialExecution?: boolean } = {}): void {
this.applyValueControlToHost(isPartialExecution)
}
private applyValueControlToHost(isPartialExecution?: boolean): void {
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
const resolved = this.resolveAtHost()
const next = nextValueForLinkedTarget({
target: this,
linkedWidgets: resolved?.widget.linkedWidgets,
nodeId: this.subgraphNode.id,
isPartialExecution
})
if (next === undefined) return
this.hydrateHostValue(next)
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
}
private resolveDeepest():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
const frame = this.subgraphNode.rootGraph.primaryCanvas?.frame
if (frame !== undefined && this.cachedDeepestFrame === frame)
return this.cachedDeepestByFrame
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
if (frame !== undefined) {
this.cachedDeepestFrame = frame
this.cachedDeepestByFrame = resolved
}
return resolved
}
private getWidgetState() {
const linkedState = this.getLinkedInputWidgetStates()[0]
if (linkedState) return linkedState
const resolved = this.resolveDeepest()
if (!resolved) return undefined
return useWidgetValueStore().getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
}
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
}): BaseWidget | undefined {
const shouldRebuild =
!this.projectedWidget ||
this.projectedSourceNode !== resolved.node ||
this.projectedSourceWidget !== resolved.widget ||
this.projectedSourceWidgetType !== resolved.widget.type
if (!shouldRebuild) return this.projectedWidget
const concrete = toConcreteWidget(resolved.widget, resolved.node, false)
if (!concrete) {
this.projectedWidget = undefined
this.projectedSourceNode = undefined
this.projectedSourceWidget = undefined
this.projectedSourceWidgetType = undefined
return undefined
}
this.projectedWidget = concrete.createCopyForNode(this.subgraphNode)
this.projectedSourceNode = resolved.node
this.projectedSourceWidget = resolved.widget
this.projectedSourceWidgetType = resolved.widget.type
return this.projectedWidget
}
private bindConcretePointerHandlers(
pointer: CanvasPointer,
canvas: LGraphCanvas,
concrete: BaseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
pointer.onClick = () =>
concrete.onClick({
e: downEvent,
node: this.subgraphNode,
canvas
})
pointer.onDrag = (eMove) =>
concrete.onDrag?.({
e: eMove,
node: this.subgraphNode,
canvas
})
return true
}
private handleLegacyMouse(
pointer: CanvasPointer,
interior: LegacyMouseWidget
): boolean {
const downEvent = pointer.eDown
if (!downEvent) return false
const downPosition: Point = [
downEvent.canvasX - this.subgraphNode.pos[0],
downEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(downEvent, downPosition, this.subgraphNode)
pointer.finally = () => {
const upEvent = pointer.eUp
if (!upEvent) return
const upPosition: Point = [
upEvent.canvasX - this.subgraphNode.pos[0],
upEvent.canvasY - this.subgraphNode.pos[1]
]
interior.mouse(upEvent, upPosition, this.subgraphNode)
}
return true
}
private syncDomOverride(
resolved:
| { node: LGraphNode; widget: IBaseWidget }
| undefined = this.resolveAtHost()
) {
if (!resolved || !isBaseDOMWidget(resolved.widget)) return
useDomWidgetStore().setPositionOverride(resolved.widget.id, {
node: this.subgraphNode,
widget: this
})
}
}
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {
return 'id' in widget && ('element' in widget || 'component' in widget)
}
function drawDisconnectedPlaceholder(
ctx: CanvasRenderingContext2D,
width: number,
y: number,
H: number
) {
const backgroundColor = readDesignToken(
'--color-secondary-background',
'#333'
)
const textColor = readDesignToken('--color-text-secondary', '#999')
const fontSize = readDesignToken('--text-2xs', '11px')
const fontFamily = readDesignToken('--font-inter', 'sans-serif')
ctx.save()
ctx.fillStyle = backgroundColor
ctx.fillRect(15, y, width - 30, H)
ctx.fillStyle = textColor
ctx.font = `${fontSize} ${fontFamily}`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(t('subgraphStore.disconnected'), width / 2, y + H / 2)
ctx.restore()
}
function readDesignToken(token: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const cachedValue = designTokenCache.get(token)
if (cachedValue) return cachedValue
const value = getComputedStyle(document.documentElement)
.getPropertyValue(token)
.trim()
const resolvedValue = value || fallback
designTokenCache.set(token, resolvedValue)
return resolvedValue
}

View File

@@ -3,46 +3,22 @@ import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { promotedInputWidget } from '@/core/graph/subgraph/promotedInputWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
function promotedInputNames(host: {
inputs: Array<{ widgetId?: unknown; name: string }>
}) {
return host.inputs
.filter((input) => input.widgetId)
.map((input) => input.name)
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
}
function promotedHostWidgetNames(host: { widgets?: IBaseWidget[] }) {
return host.widgets?.map((widget) => widget.name) ?? []
}
function writePromotedInputValue(
host: { inputs: Array<{ widgetId?: WidgetId; name: string }> },
name: string,
value: IBaseWidget['value']
) {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
useWidgetValueStore().setValue(input.widgetId, value)
}
function promotedWidgetRef(host: SubgraphNode, name: string): IBaseWidget {
const input = host.inputs.find((input) => input.name === name)
if (!input?.widgetId) throw new Error(`Missing promoted input ${name}`)
const widget = promotedInputWidget(input)
if (!widget) throw new Error(`Missing promoted input ${name}`)
return widget
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
}
const updatePreviewsMock = vi.hoisted(() => vi.fn())
@@ -55,9 +31,11 @@ import {
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
getWidgetName,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected,
@@ -190,18 +168,15 @@ describe('pruneDisconnected', () => {
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
})
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
const keptWidgetId = subgraphNode.inputs.find(
(input) => input.name === 'kept'
)?.widgetId
if (!keptWidgetId) throw new Error('Missing kept widgetId')
for (const input of [missingWidgetInput, missingNodeInput]) {
const hostInput = subgraphNode.inputs.find(
(entry) => entry._subgraphSlot === input
)
if (!hostInput) throw new Error(`Missing host input ${input.name}`)
hostInput.widgetId = keptWidgetId
}
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: '9999',
sourceWidgetName: 'missing-node'
})
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -326,25 +301,6 @@ describe('promoteRecommendedWidgets', () => {
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('preserves the source slot label when promoting a value widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Prompt')
const input = interiorNode.addInput('text', 'STRING')
input.label = 'renamed_from_sidepanel'
const textWidget = interiorNode.addWidget('text', 'text', '', () => {})
input.widget = { name: textWidget.name }
subgraph.add(interiorNode)
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, textWidget)
const hostInput = subgraphNode.inputs.find((input) => input.name === 'text')
expect(hostInput?.label).toBe('renamed_from_sidepanel')
expect(promotedWidgetRef(subgraphNode, 'text').label).toBe(
'renamed_from_sidepanel'
)
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -529,45 +485,79 @@ describe('isLinkedPromotion', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
const node = new LGraphNode('Source')
const input = node.addInput(widgetName, 'STRING')
const widget = node.addWidget('text', widgetName, '', () => {})
input.widget = { name: widget.name }
host.subgraph.add(node)
promoteValueWidgetViaSubgraphInput(host, node, widget)
return node
function linkedWidget(
sourceNodeId: string,
sourceWidgetName: string,
extra: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: 'value',
type: 'text',
value: '',
options: {},
y: 0,
...extra
} as unknown as IBaseWidget
}
it('returns true for a linked promotion', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
function createSubgraphWithInputs(count = 1) {
const subgraph = createTestSubgraph({
inputs: Array.from({ length: count }, (_, i) => ({
name: `input_${i}`,
type: 'STRING' as const
}))
})
return createTestSubgraphNode(subgraph)
}
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
it('returns true when an input has a matching _widget', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(true)
})
it('returns false when no promotion exists', () => {
const host = createTestSubgraphNode(createTestSubgraph())
it('returns false when no inputs exist or none match', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '999', 'nonexistent')).toBe(false)
})
it('returns false when sourceWidgetName does not match', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const node = promoteSource(host, 'text')
it('returns false when sourceNodeId matches but sourceWidgetName does not', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('3', 'text')
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '3', 'wrong_name')).toBe(false)
})
it('identifies linked widgets across different inputs', () => {
const host = createTestSubgraphNode(createTestSubgraph())
const nodeA = promoteSource(host, 'string_a')
const nodeB = promoteSource(host, 'value')
it('returns false when _widget is undefined on input', () => {
const subgraphNode = createSubgraphWithInputs()
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '3', 'text')).toBe(false)
})
it('matches by sourceNodeId even when disambiguatingSourceNodeId is present', () => {
const subgraphNode = createSubgraphWithInputs()
subgraphNode.inputs[0]._widget = linkedWidget('6', 'text', {
disambiguatingSourceNodeId: '1'
})
expect(isLinkedPromotion(subgraphNode, '6', 'text')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '1', 'text')).toBe(false)
})
it('identifies multiple linked widgets across different inputs', () => {
const subgraphNode = createSubgraphWithInputs(2)
subgraphNode.inputs[0]._widget = linkedWidget('3', 'string_a')
subgraphNode.inputs[1]._widget = linkedWidget('4', 'value')
expect(isLinkedPromotion(subgraphNode, '3', 'string_a')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '4', 'value')).toBe(true)
expect(isLinkedPromotion(subgraphNode, '3', 'value')).toBe(false)
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
})
})
@@ -617,13 +607,17 @@ describe('reorderSubgraphInputsByName', () => {
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
expect(promotedInputNames(host)).toEqual(['first', 'second'])
expect(promotedHostWidgetNames(host)).toEqual(['first', 'second'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'first',
'second'
])
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(promotedInputNames(host)).toEqual(['second', 'first'])
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
})
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
@@ -643,13 +637,15 @@ describe('reorderSubgraphInputsByName', () => {
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
subgraph.addInput('plain', 'STRING')
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
writePromotedInputValue(host, 'first', 'first value')
writePromotedInputValue(host, 'second', 'second value')
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
expect(promotedInputNames(host)).toEqual(['second', 'first'])
expect(promotedHostWidgetNames(host)).toEqual(['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
@@ -731,21 +727,15 @@ describe('reorderSubgraphInputsByWidgetOrder', () => {
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
writePromotedInputValue(host, 'text', 'first value')
writePromotedInputValue(host, 'text_1', 'second value')
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
const firstPromotedWidget = promotedWidgetRef(host, 'text')
const secondPromotedWidget = promotedWidgetRef(host, 'text_1')
reorderSubgraphInputsByWidgetOrder(host, [
secondPromotedWidget,
firstPromotedWidget
])
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'text_1',
'text'
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(promotedHostWidgetNames(host)).toEqual(['text_1', 'text'])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
@@ -785,10 +775,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
hostInput.link = 9999
const promotedInputId = hostInput.widgetId
const promotedViewsBefore = host.widgets.length
expect(host.subgraph.inputs).toHaveLength(1)
expect(promotedInputId).toBeDefined()
expect(promotedViewsBefore).toBeGreaterThan(0)
demoteWidget(interiorNode, interiorWidget, [host])
@@ -798,9 +788,13 @@ describe('demoteWidget — axiomatic projection retraction', () => {
expect(
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
).toBe(false)
expect(host.widgets).toHaveLength(0)
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
expect(
host.widgets.some(
(widget) =>
widgetSourceNodeId(widget) === String(interiorNode.id) &&
widget.name === interiorWidget.name
)
).toBe(false)
})
it('removes the slot entirely when host slot has no external link', () => {
@@ -818,7 +812,12 @@ describe('demoteWidget — axiomatic projection retraction', () => {
const { host, nodeA, widgetA, nodeB, widgetB } =
buildDuplicateNamePromotion()
demoteWidget(nodeB, widgetB, [host])
const promotedViewForB = host.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(promotedViewForB!.name).toBe('text_1')
demoteWidget(nodeB, promotedViewForB!, [host])
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
@@ -826,19 +825,15 @@ describe('demoteWidget — axiomatic projection retraction', () => {
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const input of innerHost.inputs) {
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, input.name)
).ok
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
@@ -846,7 +841,12 @@ describe('demoteWidget — axiomatic projection retraction', () => {
'text_1'
])
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
const innerViewForB = innerHost.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(innerViewForB!.name).toBe('text_1')
demoteWidget(innerHost, innerViewForB!, [outerHost])
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
@@ -863,19 +863,66 @@ describe('disambiguated nested promotion identity', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
subgraph.add(innerHost)
function linkedView(
sourceNodeId: string,
sourceWidgetName: string,
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: sourceWidgetName,
type: 'text',
value: '',
options: {},
y: 0,
...overrides
} as unknown as IBaseWidget
}
expect(
promoteValueWidgetViaSubgraphInput(
host,
innerHost,
promotedWidgetRef(innerHost, 'text_1')
).ok
).toBe(true)
function createSubgraphHost() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
return createTestSubgraphNode(subgraph)
}
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
const host = createSubgraphHost()
host.inputs[0]._widget = linkedView('inner', 'text_1')
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
const interiorNode = {
id: 'inner',
title: 'inner',
type: 'inner'
} as unknown as LGraphNode
const source = {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(interiorWidget)
}
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
true
)
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
const host = createTestSubgraphNode(subgraph)
const nestedSubgraphNode = {
id: 'inner',
title: 'inner',
type: 'inner',
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
} as unknown as LGraphNode
subgraph.add(nestedSubgraphNode)
host.inputs[0]._widget = linkedView('inner', 'text_1')
pruneDisconnected(host)
@@ -909,13 +956,9 @@ describe('disambiguated nested promotion identity', () => {
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const input of innerHost.inputs) {
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(
outerHost,
innerHost,
promotedWidgetRef(innerHost, input.name)
).ok
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}

View File

@@ -1,6 +1,6 @@
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -18,9 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { readWidgetValue } from '@/world/widgetValueIO'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
@@ -48,47 +46,16 @@ export function findHostInputForPromotion(
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
const source = input._subgraphSlot
? resolvePromotionSource(subgraphNode, input._subgraphSlot)
: undefined
const w = input._widget
return (
source?.sourceNodeId === sourceNodeId &&
source.sourceWidgetName === sourceWidgetName
w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === sourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
})
}
function resolvePromotionSource(
subgraphNode: SubgraphNode,
subgraphInput: { linkIds: readonly number[] }
): PromotedWidgetSource | undefined {
for (const linkId of subgraphInput.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraphNode.subgraph)
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
if (!targetInput) continue
if (inputNode.isSubgraphNode()) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.name
}
}
}
export function reorderSubgraphInputsByName(
subgraphNode: SubgraphNode,
orderedInputNames: readonly string[]
@@ -111,12 +78,13 @@ export function reorderSubgraphInputsByName(
export function reorderSubgraphInputsByWidgetOrder(
subgraphNode: SubgraphNode,
orderedWidgets: readonly Pick<IBaseWidget, 'widgetId'>[]
orderedWidgets: readonly IBaseWidget[]
): void {
const remainingIndices = new Set(subgraphNode.inputs.keys())
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
for (const index of remainingIndices) {
if (isSamePromotedInput(subgraphNode, index, orderedWidget)) {
const widget = subgraphNode.inputs[index]?._widget
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
remainingIndices.delete(index)
return [index]
}
@@ -133,48 +101,37 @@ function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const widgetValues = subgraphNode.inputs.map((input) => {
const id = input?.widgetId
if (!id) return undefined
const value = useWidgetValueStore().getWidget(id)?.value
return isWidgetValue(value) ? value : undefined
})
const widgetValues = subgraphNode.inputs.map((input) =>
getExplicitHostWidgetValue(input?._widget)
)
reorderSubgraphInputs(subgraphNode, orderedIndices)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
const id = subgraphNode.inputs[newIndex]?.widgetId
if (value === undefined || !id) continue
useWidgetValueStore().setValue(id, value)
if (value === undefined) continue
const widget = subgraphNode.inputs[newIndex]?._widget
if (widget) widget.value = value
}
}
function isSamePromotedInput(
subgraphNode: SubgraphNode,
inputIndex: number,
orderedWidget: Pick<IBaseWidget, 'widgetId'>
): boolean {
const input = subgraphNode.inputs[inputIndex]
const linkedInput = input?._subgraphSlot
if (!input || !linkedInput) return false
function getExplicitHostWidgetValue(
widget: IBaseWidget | undefined
): IBaseWidget['value'] | undefined {
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
for (const linkId of linkedInput.linkIds) {
const link = subgraphNode.subgraph.getLink(linkId)
if (!link) continue
const value = readWidgetValue(widget.entityId)
return isWidgetValue(value) ? value : undefined
}
const { inputNode, input: targetInput } = link.resolve(
subgraphNode.subgraph
)
if (!inputNode || !targetInput) continue
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (targetWidget === orderedWidget) return true
if (input.widgetId && input.widgetId === orderedWidget.widgetId) return true
}
return false
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
}
function isPreviewExposed(
@@ -211,9 +168,13 @@ function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
const widgetIsParentLevelView =
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget)
sourceWidgetName: widgetIsParentLevelView
? widget.sourceWidgetName
: getWidgetName(widget)
}
}
@@ -250,53 +211,15 @@ export function promoteValueWidgetViaSubgraphInput(
inputName,
String(sourceSlot.type ?? sourceWidget.type ?? '*')
)
subgraphInput.label = sourceSlot.label
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) {
subgraphNode.subgraph.removeInput(subgraphInput)
return { ok: false, reason: 'connectFailed' }
}
const hostInput = subgraphNode.inputs.find(
(input) => input._subgraphSlot === subgraphInput
)
if (hostInput) hostInput.label = sourceSlot.label
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
return { ok: true }
}
function seedNestedPromotedInputState(
subgraphNode: SubgraphNode,
inputName: string,
sourceSlot: { widgetId?: WidgetId; label?: string }
): void {
if (!sourceSlot.widgetId) return
const hostInput = subgraphNode.inputs.find(
(input) => input._subgraphSlot?.name === inputName
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
}
function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
@@ -360,32 +283,6 @@ export function promoteWidget(
})
}
/**
* Removes the host input projecting a linked promotion identified by source.
* Returns true when an input was found and demoted.
*/
export function demotePromotedInput(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
if (!subgraphNode.subgraph) return false
const hostInput = findHostInputForPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (!linkedInput) return false
if (hostInput.link != null) {
linkedInput.disconnect()
} else {
subgraphNode.subgraph.removeInput(linkedInput)
}
return true
}
export function demoteWidget(
node: PartialNode,
widget: IBaseWidget,
@@ -395,7 +292,21 @@ export function demoteWidget(
for (const parent of parents) {
if (!parent.subgraph) continue
if (demotePromotedInput(parent, source)) continue
const hostInput = findHostInputForPromotion(
parent,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (linkedInput) {
const hasExternalLink = hostInput.link != null
if (hasExternalLink) {
linkedInput.disconnect()
} else {
parent.subgraph.removeInput(linkedInput)
}
continue
}
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
@@ -594,19 +505,37 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
const removedEntries: PromotedWidgetSource[] = []
const staleInputs = subgraph.inputs.filter((input) => {
const source = resolvePromotionSource(subgraphNode, input)
if (source) return false
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
const hostInput = subgraphNode.inputs.find(
(entry) => entry._subgraphSlot === input
// If the SubgraphInput has any live link to an interior target slot that
// still has a widget, the promotion is alive — even when the widget's
// sourceNodeId points at a deeply-nested interior node that does not exist
// directly in `subgraph` (nested SubgraphNode promotions).
for (const linkId of input.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode) continue
const targetInputSlot = inputNode.inputs?.find(
(slot) => slot.link === linkId
)
if (!targetInputSlot) continue
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
}
const node = subgraph.getNodeById(widget.sourceNodeId)
if (!node) {
removedEntries.push(widget)
return true
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === widget.sourceWidgetName
)
if (!hostInput?.widgetId && !hostInput?._widget) return false
removedEntries.push({
sourceNodeId: String(subgraphNode.id),
sourceWidgetName: input.name
})
return true
if (!hasWidget) {
removedEntries.push(widget)
}
return !hasWidget
})
for (const input of staleInputs) {

View File

@@ -1,9 +1,11 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import {
resolveConcretePromotedWidget,
resolvePromotedWidgetAtHost
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import {
@@ -22,6 +24,15 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
type PromotedWidgetStub = Pick<
IBaseWidget,
'name' | 'type' | 'options' | 'value' | 'y'
> & {
sourceNodeId: string
sourceWidgetName: string
node?: SubgraphNode
}
function createHostNode(id: number): SubgraphNode {
return createTestSubgraphNode(createTestSubgraph(), { id })
}
@@ -36,10 +47,55 @@ function addConcreteWidget(node: LGraphNode, name: string): IBaseWidget {
return node.addWidget('text', name, `${name}-value`, () => undefined)
}
function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
type: 'button',
options: {},
y: 0,
value: undefined,
sourceNodeId,
sourceWidgetName,
node
}
return promotedWidget as IBaseWidget
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('resolvePromotedWidgetAtHost', () => {
test('resolves a direct concrete widget on the host subgraph node', () => {
const host = createHostNode(100)
const concreteNode = addNodeToHost(host, 'leaf')
addConcreteWidget(concreteNode, 'seed')
const resolved = resolvePromotedWidgetAtHost(
host,
String(concreteNode.id),
'seed'
)
expect(resolved).toBeDefined()
expect(resolved?.node.id).toBe(concreteNode.id)
expect(resolved?.widget.name).toBe('seed')
})
test('returns undefined when host does not contain the target node', () => {
const host = createHostNode(100)
const resolved = resolvePromotedWidgetAtHost(host, 'missing', 'seed')
expect(resolved).toBeUndefined()
})
})
describe('resolveConcretePromotedWidget', () => {
test('resolves a direct concrete source widget', () => {
const host = createHostNode(100)
@@ -58,86 +114,102 @@ describe('resolveConcretePromotedWidget', () => {
expect(result.resolved.widget.name).toBe('seed')
})
test('descends through nested subgraph inputs to the deepest concrete widget', () => {
const innerSubgraph = createTestSubgraph({
inputs: [{ name: 'x', type: '*' }]
})
const leaf = new LGraphNode('Leaf')
const leafInput = leaf.addInput('x', '*')
leaf.addWidget('combo', 'seed', 'a', () => undefined, {
values: ['a', 'b']
})
leafInput.widget = { name: 'seed' }
innerSubgraph.add(leaf)
innerSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const innerNode = createTestSubgraphNode(innerSubgraph, { id: 11 })
const outerSubgraph = createTestSubgraph({
inputs: [{ name: 'y', type: '*' }]
})
outerSubgraph.add(innerNode)
innerNode._internalConfigureAfterSlots()
outerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const outerNode = createTestSubgraphNode(outerSubgraph, { id: 22 })
test('descends through nested promoted widgets to resolve concrete source', () => {
const rootHost = createHostNode(100)
const nestedHost = createHostNode(101)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'seed')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('outer', String(leafNode.id), 'seed', nestedHost)
]
const result = resolveConcretePromotedWidget(
outerNode,
String(innerNode.id),
'x'
rootHost,
String(sourceNode.id),
'outer'
)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leaf.id)
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('seed')
expect(result.resolved.widget.type).toBe('combo')
})
test('returns cycle when nested promoted widget traversal revisits the same input', () => {
const recursiveInput = { name: 'x', link: 1 }
const recursiveNode = fromAny<LGraphNode, unknown>({
id: 11,
inputs: [recursiveInput],
isSubgraphNode: () => true,
subgraph: {
inputNode: { slots: [{ name: 'x', linkIds: [1] }] },
getLink: () => ({
resolve: () => ({ inputNode: recursiveNode })
}),
getNodeById: () => recursiveNode
}
})
const host = fromAny<SubgraphNode, unknown>({
isSubgraphNode: () => true,
subgraph: {
getNodeById: () => recursiveNode
}
})
test('returns cycle failure when promoted widgets form a loop', () => {
const hostA = createHostNode(200)
const hostB = createHostNode(201)
const relayA = addNodeToHost(hostA, 'relayA')
const relayB = addNodeToHost(hostB, 'relayB')
const result = resolveConcretePromotedWidget(host, '11', 'x')
relayA.widgets = [
createPromotedWidget('wA', String(relayB.id), 'wB', hostB)
]
relayB.widgets = [
createPromotedWidget('wB', String(relayA.id), 'wA', hostA)
]
expect(result).toEqual({ status: 'failure', failure: 'cycle' })
const result = resolveConcretePromotedWidget(hostA, String(relayA.id), 'wA')
expect(result).toEqual({
status: 'failure',
failure: 'cycle'
})
})
test('returns max-depth-exceeded for a chain over the traversal limit', () => {
const subgraphs = Array.from({ length: 102 }, () =>
createTestSubgraph({ inputs: [{ name: 'x', type: '*' }] })
test('does not report a cycle when different host objects share an id', () => {
const rootHost = createHostNode(41)
const nestedHost = createHostNode(41)
const leafNode = addNodeToHost(nestedHost, 'leaf')
addConcreteWidget(leafNode, 'w')
const sourceNode = addNodeToHost(rootHost, 'source')
sourceNode.widgets = [
createPromotedWidget('w', String(leafNode.id), 'w', nestedHost)
]
const result = resolveConcretePromotedWidget(
rootHost,
String(sourceNode.id),
'w'
)
for (let index = 0; index < subgraphs.length - 1; index++) {
const current = subgraphs[index]
const next = subgraphs[index + 1]
const nextNode = createTestSubgraphNode(next, { id: index + 1 })
current.add(nextNode)
nextNode._internalConfigureAfterSlots()
current.inputNode.slots[0].connect(nextNode.inputs[0], nextNode)
expect(result.status).toBe('resolved')
if (result.status !== 'resolved') return
expect(result.resolved.node.id).toBe(leafNode.id)
expect(result.resolved.widget.name).toBe('w')
})
test('returns max-depth-exceeded for very deep non-cyclic promoted chains', () => {
const hosts = Array.from({ length: 102 }, (_, index) =>
createHostNode(index + 1)
)
const relayNodes = hosts.map((host, index) =>
addNodeToHost(host, `relay-${index}`)
)
for (let index = 0; index < relayNodes.length - 1; index += 1) {
relayNodes[index].widgets = [
createPromotedWidget(
`w-${index}`,
String(relayNodes[index + 1].id),
`w-${index + 1}`,
hosts[index + 1]
)
]
}
const host = createTestSubgraphNode(subgraphs[0], { id: 200 })
addConcreteWidget(
relayNodes[relayNodes.length - 1],
`w-${relayNodes.length - 1}`
)
const result = resolveConcretePromotedWidget(
hosts[0],
String(relayNodes[0].id),
'w-0'
)
const result = resolveConcretePromotedWidget(host, '1', 'x')
expect(result).toEqual({
status: 'failure',
failure: 'max-depth-exceeded'

View File

@@ -1,7 +1,8 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type PromotedWidgetResolutionFailure =
| 'invalid-host'
@@ -40,17 +41,6 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-node' }
}
if (sourceNode.isSubgraphNode()) {
const target = resolveSubgraphInputTarget(sourceNode, currentWidgetName)
if (!target) {
return { status: 'failure', failure: 'missing-widget' }
}
currentHost = sourceNode
currentNodeId = target.nodeId
currentWidgetName = target.widgetName
continue
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
)
@@ -58,15 +48,39 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-widget' }
}
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
if (!isPromotedWidgetView(sourceWidget)) {
return {
status: 'resolved',
resolved: { node: sourceNode, widget: sourceWidget }
}
}
if (!sourceWidget.node?.isSubgraphNode()) {
return { status: 'failure', failure: 'missing-node' }
}
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find((entry) => entry.name === widgetName)
if (!widget) return undefined
return { node, widget }
}
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
@@ -77,3 +91,20 @@ export function resolveConcretePromotedWidget(
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved
return undefined
}

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

@@ -1,5 +1,7 @@
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
@@ -7,7 +9,9 @@ export function getStableWidgetRenderKey(widget: IBaseWidget): string {
const cachedKey = widgetRenderKeys.get(widget)
if (cachedKey) return cachedKey
const key = `widget:${nextWidgetRenderKeyId++}`
const prefix = isPromotedWidgetView(widget) ? 'promoted' : 'widget'
const key = `${prefix}:${nextWidgetRenderKeyId++}`
widgetRenderKeys.set(widget, key)
return key
}

View File

@@ -1,7 +0,0 @@
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export const IS_CONTROL_WIDGET = Symbol()
export function isValueControlWidget(widget: IBaseWidget): boolean {
return (widget as Record<symbol, unknown>)[IS_CONTROL_WIDGET] === true
}

View File

@@ -1,35 +0,0 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { UUID } from '@/utils/uuid'
import { isValueControlWidget } from './controlWidgetMarker'
/**
* Registers (or clears) the control component for a target widget, derived from
* its linked widgets. Called wherever the target's value is registered, so
* control state follows the same deferral as value state.
*/
export function syncWidgetControl(
targetId: WidgetId,
graphId: UUID,
nodeId: NodeId,
linkedWidgets: readonly IBaseWidget[] | undefined
): void {
const store = useWidgetValueStore()
const control = linkedWidgets?.find(isValueControlWidget)
if (!control) {
store.deleteWidgetControl(targetId)
return
}
const filter = linkedWidgets?.find(
(widget) => widget !== control && widget.type === 'string'
)
store.registerWidgetControl(targetId, {
controlWidgetId: widgetId(graphId, nodeId, control.name),
filterWidgetId: filter ? widgetId(graphId, nodeId, filter.name) : undefined
})
}

View File

@@ -24,7 +24,6 @@ import { useLitegraphService } from '@/services/litegraphService'
import { app } from '@/scripts/app'
import type { ComfyApp } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
const INLINE_INPUTS = false
@@ -191,9 +190,7 @@ function dynamicComboWidget(
const getState = () => {
const graphId = resolveNodeRootGraphId(node)
if (!graphId) return undefined
return useWidgetValueStore().getWidget(
widgetId(graphId, node.id, widget.name)
)
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
}
Object.defineProperty(widget, 'value', {
get() {

View File

@@ -1,172 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import { IS_CONTROL_WIDGET } from '@/core/graph/widgets/control/controlWidgetMarker'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { runWidgetControl } from './widgetControlSystem'
const controlMode = vi.hoisted(() => ({ value: 'after' as 'before' | 'after' }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.WidgetControlMode' ? controlMode.value : undefined
})
}))
function markControl(widget: IBaseWidget): IBaseWidget {
;(widget as IBaseWidget & Record<symbol, unknown>)[IS_CONTROL_WIDGET] = true
return widget
}
function addSeedNode(
graph: LGraph,
{ mode = 'increment', value = 1 }: { mode?: string; value?: number } = {}
): LGraphNode {
const node = new LGraphNode('SeedNode')
node.id = 1
const seed = node.addWidget('number', 'seed', value, () => {}, {
min: 0,
max: 1_000_000,
step2: 1
})
const control = node.addWidget(
'combo',
'control_after_generate',
mode,
() => {},
{ values: ['fixed', 'increment', 'decrement', 'randomize'] }
)
markControl(control)
seed.linkedWidgets = [control]
graph.add(node)
return node
}
function seedValue(node: LGraphNode): unknown {
const store = useWidgetValueStore()
return store.getWidget(node.widgets![0].widgetId!)?.value
}
describe('runWidgetControl', () => {
let graph: LGraph
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
controlMode.value = 'after'
graph = new LGraph()
graph.id = 'graph-a'
})
it('increments a controlled value after queueing', () => {
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(2)
})
it('leaves the value unchanged when the mode is fixed', () => {
const node = addSeedNode(graph, { mode: 'fixed' })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(1)
})
it('does not run on a target whose input is link-fed', () => {
const node = addSeedNode(graph, { mode: 'increment' })
node.addInput('seed', 'number', { link: 1, widget: { name: 'seed' } })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(1)
})
it('does not run during partial execution', () => {
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'after', { isPartialExecution: true })
expect(seedValue(node)).toBe(1)
})
it('skips the first queue in before mode, then advances', () => {
controlMode.value = 'before'
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'before')
expect(seedValue(node)).toBe(1)
runWidgetControl(graph, 'before')
expect(seedValue(node)).toBe(2)
})
it('ignores after-phase work when in before mode', () => {
controlMode.value = 'before'
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(1)
})
it('applies a combo filter when advancing a combo value', () => {
const store = useWidgetValueStore()
const node = new LGraphNode('CkptNode')
node.id = 1
const ckpt = node.addWidget('combo', 'ckpt', 'a.safetensors', () => {}, {
values: ['a.safetensors', 'b.ckpt', 'c.safetensors']
})
const control = markControl(
node.addWidget('combo', 'control_after_generate', 'increment', () => {}, {
values: ['fixed', 'increment', 'decrement', 'randomize']
})
)
const filter = node.addWidget(
'string',
'control_filter_list',
'safetensors',
() => {},
{}
)
ckpt.linkedWidgets = [control, filter]
graph.add(node)
runWidgetControl(graph, 'after')
expect(store.getWidget(ckpt.widgetId!)?.value).toBe('c.safetensors')
})
it('only advances controls belonging to the queued graph', () => {
const node = addSeedNode(graph, { mode: 'increment' })
const otherGraph = new LGraph()
otherGraph.id = 'graph-b'
const otherNode = addSeedNode(otherGraph, { mode: 'increment' })
runWidgetControl(graph, 'after')
expect(seedValue(node)).toBe(2)
expect(seedValue(otherNode)).toBe(1)
})
it('preserves the before-mode skip when the widget re-registers', () => {
controlMode.value = 'before'
const node = addSeedNode(graph, { mode: 'increment' })
runWidgetControl(graph, 'before')
expect(seedValue(node)).toBe(1)
const seed = node.widgets![0]
if (isNodeBindable(seed)) seed.setNodeId(node.id)
runWidgetControl(graph, 'before')
expect(seedValue(node)).toBe(2)
})
})

View File

@@ -1,77 +0,0 @@
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { parseWidgetId, widgetId } from '@/types/widgetId'
import { forEachNode } from '@/utils/graphTraversalUtil'
import {
computeNextControlledValue,
isValueControlMode
} from './control/valueControl'
export type WidgetControlPhase = 'before' | 'after'
/**
* Widget ids whose input slot is currently link-fed, so their value comes from
* upstream and control must not advance it. Derived live from the graph.
*/
function collectLinkFedTargets(graph: LGraph): Set<WidgetId> {
const graphId = graph.rootGraph.id
const linkFed = new Set<WidgetId>()
forEachNode(graph, (node) => {
for (const input of node.inputs ?? []) {
if (input.link == null) continue
if (input.widgetId) {
linkFed.add(input.widgetId)
} else if (input.widget?.name) {
linkFed.add(widgetId(graphId, node.id, input.widget.name))
}
}
})
return linkFed
}
/** Advances the graph's controlled widget store values at the given queue phase. */
export function runWidgetControl(
graph: LGraph,
phase: WidgetControlPhase,
{ isPartialExecution }: { isPartialExecution?: boolean } = {}
): void {
if (isPartialExecution) return
const runBefore =
useSettingStore().get('Comfy.WidgetControlMode') === 'before'
if (phase === 'before' && !runBefore) return
if (phase === 'after' && runBefore) return
const store = useWidgetValueStore()
const linkFed = collectLinkFedTargets(graph)
for (const [targetId, control] of store.getWidgetControls(
graph.rootGraph.id
)) {
const target = store.getWidget(targetId)
if (!target || linkFed.has(targetId)) continue
if (phase === 'before' && !control.hasExecuted) {
control.hasExecuted = true
continue
}
control.hasExecuted = true
const mode = store.getWidget(control.controlWidgetId)?.value
if (!isValueControlMode(mode)) continue
const filter = control.filterWidgetId
? store.getWidget(control.filterWidgetId)?.value
: undefined
const next = computeNextControlledValue(target, mode, {
comboFilter: typeof filter === 'string' ? filter : undefined,
nodeId: parseWidgetId(targetId).nodeId
})
if (next === undefined) continue
store.setValue(targetId, next)
}
}

View File

@@ -8,7 +8,6 @@ import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
import { widgetId } from '@/types/widgetId'
function applyToGraph(this: LGraphNode, extraLinks: LLink[] = []) {
applyFirstWidgetValueToGraph(this, extraLinks)
@@ -52,15 +51,16 @@ function onCustomComboCreated(this: LGraphNode) {
Object.defineProperty(widget, 'value', {
get() {
return (
useWidgetValueStore().getWidget(
widgetId(app.rootGraph.id, node.id, widgetName)
)?.value ?? localValue
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
?.value ?? localValue
)
},
set(v: string) {
localValue = v
const state = useWidgetValueStore().getWidget(
widgetId(app.rootGraph.id, node.id, widgetName)
app.rootGraph.id,
node.id,
widgetName
)
if (state) state.value = v
updateCombo()

View File

@@ -20,7 +20,6 @@ import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { type NodeLocatorId } from '@/types'
import { widgetId } from '@/types/widgetId'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
import { api } from '../../scripts/api'
@@ -153,16 +152,16 @@ app.registerExtension({
audioUIWidget.options.getValue = () =>
(useWidgetValueStore().getWidget(
widgetId(
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)
resolveNodeRootGraphId(node, app.rootGraph.id),
node.id,
inputName
)?.value as string) ?? ''
audioUIWidget.options.setValue = (v) => {
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
const widgetState = useWidgetValueStore().getWidget(
widgetId(graphId, node.id, inputName)
graphId,
node.id,
inputName
)
if (widgetState) widgetState.value = v
}

View File

@@ -16,7 +16,6 @@ import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import {
createTestSubgraph,
createTestSubgraphData,
@@ -297,8 +296,9 @@ describe('Graph Clearing and Callbacks', () => {
})
const widgetValueStore = useWidgetValueStore()
const seedWidgetId = widgetId(graphId, '10' as NodeId, 'seed')
widgetValueStore.registerWidget(seedWidgetId, {
widgetValueStore.registerWidget(graphId, {
nodeId: '10' as NodeId,
name: 'seed',
type: 'number',
value: 1,
options: {},
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(widgetValueStore.getWidget(seedWidgetId)).toEqual(
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
@@ -316,7 +316,9 @@ describe('Graph Clearing and Callbacks', () => {
graph.clear()
expect(widgetValueStore.getWidget(seedWidgetId)).toBeUndefined()
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)

View File

@@ -96,9 +96,11 @@ import { BaseWidget } from './widgets/BaseWidget'
import { toConcreteWidget } from './widgets/widgetMap'
import type { WidgetTypeMap } from './widgets/widgetMap'
import type { NodeId } from '@/world/entityIds'
// #region Types
export type NodeId = number | string
export type { NodeId }
export type NodeProperty = string | number | boolean | object
@@ -4239,9 +4241,7 @@ export class LGraphNode
if (!widget) continue
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const pos: [number, number] = [offset, widget.y + offset]
slot.pos = pos
this.inputs[i].pos = pos
slot.pos = [offset, widget.y + offset]
this._measureSlot(slot, i, true)
}
}

View File

@@ -1,6 +1,6 @@
import type { Rectangle } from './infrastructure/Rectangle'
import type { CanvasColour } from './interfaces'
import { litegraph } from './litegraphInstance'
import { LiteGraph } from './litegraph'
import { RenderShape, TitleMode } from './types/globalEnums'
import { cachedMeasureText } from './utils/textMeasureCache'
@@ -81,12 +81,12 @@ export function strokeShape(
}: IDrawBoundingOptions = {}
): void {
// These param defaults are not compile-time static, and must be re-evaluated at runtime
round_radius ??= litegraph().ROUND_RADIUS
color ??= litegraph().NODE_BOX_OUTLINE_COLOR
round_radius ??= LiteGraph.ROUND_RADIUS
color ??= LiteGraph.NODE_BOX_OUTLINE_COLOR
// Adjust area if title is transparent
if (title_mode === TitleMode.TRANSPARENT_TITLE) {
const height = title_height ?? litegraph().NODE_TITLE_HEIGHT
const height = title_height ?? LiteGraph.NODE_TITLE_HEIGHT
area[1] -= height
area[3] += height
}

View File

@@ -1,6 +1,5 @@
import type { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { WidgetId } from '@/types/widgetId'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { ContextMenu } from './ContextMenu'
@@ -363,7 +362,6 @@ export interface IWidgetLocator {
export interface INodeInputSlot extends INodeSlot {
link: LinkId | null
widget?: IWidgetLocator
widgetId?: WidgetId
alwaysVisible?: boolean
/**

View File

@@ -7,7 +7,6 @@ import type {
Point,
Size
} from './interfaces'
import { registerLiteGraphInstance } from './litegraphInstance'
import { loadPolyfills } from './polyfills'
import type { CanvasEventDetail } from './types/events'
import type { RenderShape, TitleMode } from './types/globalEnums'
@@ -16,7 +15,6 @@ import type { RenderShape, TitleMode } from './types/globalEnums'
export { Subgraph } from './subgraph/Subgraph'
export const LiteGraph = new LiteGraphGlobal()
registerLiteGraphInstance(LiteGraph)
// Load legacy polyfills
loadPolyfills()

View File

@@ -1,26 +0,0 @@
import type { LiteGraphGlobal } from './LiteGraphGlobal'
/**
* Late-bound holder for the {@link LiteGraphGlobal} singleton.
*
* This module imports `LiteGraphGlobal` as a type only, so it has no runtime
* dependencies. Modules in the widget initialisation chain (e.g. `draw.ts`,
* imported transitively by `BaseWidget`) can read singleton constants through
* {@link litegraph} without importing the `litegraph` barrel — which would
* re-enter the barrel mid-initialisation and evaluate
* `LegacyWidget extends BaseWidget` before `BaseWidget` is defined.
*
* The barrel constructs the singleton and calls {@link registerLiteGraphInstance}.
*/
let instance: LiteGraphGlobal | null = null
export function registerLiteGraphInstance(value: LiteGraphGlobal): void {
instance = value
}
export function litegraph(): LiteGraphGlobal {
if (!instance) {
throw new Error('LiteGraph singleton accessed before initialisation')
}
return instance
}

View File

@@ -2,7 +2,6 @@ import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import { InvalidLinkError } from '@/lib/litegraph/src/infrastructure/InvalidLinkError'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { RecursionError } from '@/lib/litegraph/src/infrastructure/RecursionError'
import { SlotIndexError } from '@/lib/litegraph/src/infrastructure/SlotIndexError'
@@ -184,14 +183,15 @@ export class ExecutableNodeDTO implements ExecutableLGraphNode {
// Nothing connected
const linkId = subgraphNodeInput.link
if (linkId == null) {
const id = subgraphNodeInput.widgetId
if (!id) return
const widget = subgraphNode.getWidgetFromSlot(subgraphNodeInput)
if (!widget) return
// Special case: SubgraphNode widget.
return {
node: this,
origin_id: this.id,
origin_slot: -1,
widgetInfo: { value: useWidgetValueStore().getWidget(id)?.value }
widgetInfo: { value: widget.value }
}
}

View File

@@ -0,0 +1,128 @@
import { describe, expect, test } from 'vitest'
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
type TestPromotionEntry = {
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
}
function makeView(entry: TestPromotionEntry) {
const baseKey = `${entry.sourceNodeId}:${entry.sourceWidgetName}`
return {
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
}
}
describe('PromotedWidgetViewManager', () => {
test('returns memoized array when entries reference is unchanged', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const entries = [{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }]
const first = manager.reconcile(entries, makeView)
const second = manager.reconcile(entries, makeView)
expect(second).toBe(first)
expect(second[0]).toBe(first[0])
})
test('preserves view identity while reflecting order changes', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }
],
makeView
)
const reordered = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(reordered[0]).toBe(firstPass[1])
expect(reordered[1]).toBe(firstPass[0])
})
test('deduplicates by first occurrence and clears stale cache entries', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const first = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(first.map((view) => view.key)).toStrictEqual([
'1:widgetA',
'1:widgetB'
])
manager.reconcile(
[{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }],
makeView
)
const restored = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
expect(restored[0]).toBe(first[1])
expect(restored[1]).not.toBe(first[0])
})
test('keeps distinct views for same source widget when viewKeys differ', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const views = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(views).toHaveLength(2)
expect(views[0]).not.toBe(views[1])
expect(views[0].key).toBe('1:widgetA:slotA')
expect(views[1].key).toBe('1:widgetA:slotB')
})
test('removeByViewKey removes only the targeted keyed view', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const firstPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
manager.removeByViewKey('1', 'widgetA', 'slotA')
const secondPass = manager.reconcile(
[
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
expect(secondPass[0]).not.toBe(firstPass[0])
expect(secondPass[1]).toBe(firstPass[1])
})
})

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