feat: synthetic widgets getter for SubgraphNode (proxy-widget-v2) (#8856)

## Summary

Replace the Proxy-based proxy widget system with a store-driven
architecture where `promotionStore` and `widgetValueStore` are the
single sources of truth for subgraph widget promotion and widget values,
and `SubgraphNode.widgets` is a synthetic getter composing lightweight
`PromotedWidgetView` objects from store state.

## Motivation

The subgraph widget promotion system previously scattered state across
multiple unsynchronized layers:

- **Persistence**: `node.properties.proxyWidgets` (tuples on the
LiteGraph node)
- **Runtime**: Proxy-based `proxyWidget.ts` with `Overlay` objects,
`DisconnectedWidget` singleton, and `isProxyWidget` type guards
- **UI**: Each Vue component independently calling `parseProxyWidgets()`
via `customRef` hacks
- **Mutation flags**: Imperative `widget.promoted = true/false` set on
`subgraph-opened` events

This led to 4+ independent parsings of the same data, complex cache
invalidation, and no reactive contract between the promotion state and
the rendering layer. Widget values were similarly owned by LiteGraph
with no Vue-reactive backing.

The core principle driving these changes: **Vue owns truth**. Pinia
stores are the canonical source; LiteGraph objects delegate to stores
via getters/setters; Vue components react to store state directly.

## Changes

### New stores (single sources of truth)

- **`promotionStore`** — Reactive `Map<NodeId, PromotionEntry[]>`
tracking which interior widgets are promoted on which SubgraphNode
instances. Graph-scoped by root graph ID to prevent cross-workflow state
collision. Replaces `properties.proxyWidgets` parsing, `customRef`
hacks, `widget.promoted` mutation, and the `subgraph-opened` event
listener.
- **`widgetValueStore`** — Graph-scoped `Map<WidgetKey, WidgetState>`
that is the canonical owner of widget values. `BaseWidget.value`
delegates to this store via getter/setter when a node ID is assigned.
Eliminates the need for Proxy-based value forwarding.

### Synthetic widgets getter (SubgraphNode)

`SubgraphNode.widgets` is now a getter that reads
`promotionStore.getPromotions(rootGraphId, nodeId)` and returns cached
`PromotedWidgetView` objects. No stubs, no Proxies, no fake widgets
persisted in the array. The setter is a no-op — mutations go through
`promotionStore`.

### PromotedWidgetView

A class behind a `createPromotedWidgetView` factory, implementing the
`PromotedWidgetView` interface. Delegates value/type/options/drawing to
the resolved interior widget and stores. Owns positional state (`y`,
`computedHeight`) for canvas layout. Cached by
`PromotedWidgetViewManager` for object-identity stability across frames.

### DOM widget promotion

Promoted DOM widgets (textarea, image upload, etc.) render on the
SubgraphNode surface via `positionOverride` in `domWidgetStore`.
`DomWidgets.vue` checks for overrides and uses the SubgraphNode's
coordinates instead of the interior node's.

### Promoted previews

New `usePromotedPreviews` composable resolves image/audio/video preview
widgets from promoted entries, enabling SubgraphNodes to display
previews of interior preview nodes.

### Deleted

- `proxyWidget.ts` (257 lines) — Proxy handler, `Overlay`,
`newProxyWidget`, `isProxyWidget`
- `DisconnectedWidget.ts` (39 lines) — Singleton Proxy target
- `useValueTransform.ts` (32 lines) — Replaced by store delegation

### Key architectural changes

- `BaseWidget.value` getter/setter delegates to `widgetValueStore` when
node ID is set
- `LGraph.add()` reordered: `node.graph` assigned before widget
`setNodeId` (enables store registration)
- `LGraph.clear()` cleans up graph-scoped stores to prevent stale
entries across workflow switches
- `promotionStore` and `widgetValueStore` state nested under root graph
UUID for multi-workflow isolation
- `SubgraphNode.serialize()` writes promotions back to
`properties.proxyWidgets` for persistence compatibility
- Legacy `-1` promotion entries resolved and migrated on first load with
dev warning

## Test coverage

- **3,700+ lines of new/updated tests** across 36 test files
- **Unit**: `promotionStore.test.ts`, `widgetValueStore.test.ts`,
`promotedWidgetView.test.ts` (921 lines),
`subgraphNodePromotion.test.ts`, `proxyWidgetUtils.test.ts`,
`DomWidgets.test.ts`, `PromotedWidgetViewManager.test.ts`,
`usePromotedPreviews.test.ts`, `resolvePromotedWidget.test.ts`,
`subgraphPseudoWidgetCache.test.ts`
- **E2E**: `subgraphPromotion.spec.ts` (622 lines) — promote/demote,
manual/auto promotion, paste preservation, seed control augmentation,
image preview promotion; `imagePreview.spec.ts` extended with
multi-promoted-preview coverage
- **Fixtures**: 2 new subgraph workflow fixtures for preview promotion
scenarios

## Review focus

- Graph-scoped store keying (`rootGraphId`) — verify isolation across
workflows/tabs and cleanup on `LGraph.clear()`
- `PromotedWidgetView` positional stability — `_arrangeWidgets` writes
to `y`/`computedHeight` on cached objects; getter returns fresh array
but stable object references
- DOM widget position override lifecycle — overrides set on promote,
cleared on demote/removal/subgraph navigation
- Legacy `-1` entry migration — resolved and written back on first load;
unresolvable entries dropped with dev warning
- Serialization round-trip — `promotionStore` state →
`properties.proxyWidgets` on serialize, hydrated back on configure

## Diff breakdown (excluding lockfile)

- 153 files changed, ~7,500 insertions, ~1,900 deletions (excluding
pnpm-lock.yaml churn)
- ~3,700 lines are tests
- ~300 lines deleted (proxyWidget.ts, DisconnectedWidget.ts,
useValueTransform.ts)

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8856-feat-synthetic-widgets-getter-for-SubgraphNode-proxy-widget-v2-3076d73d365081c7b517f5ec7cb514f3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-02-23 13:33:41 -08:00
committed by GitHub
parent d7546e68ef
commit c25f9a0e93
128 changed files with 7295 additions and 1931 deletions

View File

@@ -1,4 +1,5 @@
import { isProxyWidget } from '@/core/graph/subgraph/proxyWidget'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -31,12 +32,12 @@ export function getWidgetDefaultValue(
/**
* Renames a widget and its corresponding input.
* Handles both regular widgets and proxy widgets in subgraphs.
* Handles both regular widgets and promoted widget views in subgraphs.
*
* @param widget The widget to rename
* @param node The node containing the widget
* @param newLabel The new label for the widget (empty string or undefined to clear)
* @param parents Optional array of parent SubgraphNodes (for proxy widgets)
* @param parents Optional array of parent SubgraphNodes (for promoted widgets)
* @returns true if the rename was successful, false otherwise
*/
export function renameWidget(
@@ -45,46 +46,28 @@ export function renameWidget(
newLabel: string,
parents?: SubgraphNode[]
): boolean {
// For proxy widgets in subgraphs, we need to rename the original interior widget
if (isProxyWidget(widget) && parents?.length) {
const subgraph = parents[0].subgraph
if (!subgraph) {
console.error('Could not find subgraph for proxy widget')
return false
}
const interiorNode = subgraph.getNodeById(widget._overlay.nodeId)
if (!interiorNode) {
console.error('Could not find interior node for proxy widget')
if (isPromotedWidgetView(widget) && parents?.length) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
return false
}
const originalWidget = interiorNode.widgets?.find(
(w) => w.name === widget._overlay.widgetName
)
const originalWidget = sourceWidget.widget
const interiorNode = sourceWidget.node
if (!originalWidget) {
console.error('Could not find original widget for proxy widget')
return false
}
// Rename the original widget
originalWidget.label = newLabel || undefined
// Also rename the corresponding input on the interior node
const interiorInput = interiorNode.inputs?.find(
(inp) => inp.widget?.name === widget._overlay.widgetName
(inp) => inp.widget?.name === originalWidget.name
)
if (interiorInput) {
interiorInput.label = newLabel || undefined
}
}
// Always rename the widget on the current node (either regular widget or proxy widget)
const input = node.inputs?.find((inp) => inp.widget?.name === widget.name)
// Intentionally mutate the widget object here as it's a reference
// to the actual widget in the graph
widget.label = newLabel || undefined
if (input) {
input.label = newLabel || undefined