Files
ComfyUI_frontend/browser_tests/tests/changeTracker.spec.ts
Alexander Brown c25f9a0e93 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>
2026-02-23 13:33:41 -08:00

172 lines
6.0 KiB
TypeScript

import type { ComfyPage } from '../fixtures/ComfyPage'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.canvas!.emitBeforeChange()
})
}
async function afterChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
window.app!.canvas!.emitAfterChange()
})
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Change Tracker', { tag: '@workflow' }, () => {
test.describe('Undo/Redo', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Can undo multiple operations', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
expect(await comfyPage.toast.getToastErrorCount()).toBe(0)
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(2)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(0)
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(1)
await comfyPage.keyboard.undo()
await expect(node).not.toBeCollapsed()
expect(await comfyPage.workflow.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
expect(await comfyPage.workflow.getRedoQueueSize()).toBe(2)
})
})
test('Can group multiple change actions into a single transaction', async ({
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
expect(node).toBeTruthy()
await expect(node).not.toBeCollapsed()
await expect(node).not.toBeBypassed()
// Make changes outside set
// Bypass + collapse node
await node.click('title')
await node.click('collapse')
await comfyPage.keyboard.bypass()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// Undo, undo, ensure both changes undone
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).toBeCollapsed()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
// Prevent clicks registering a double-click
await comfyPage.canvasOps.clickEmptySpace()
await node.click('title')
// Run again, but within a change transaction
await beforeChange(comfyPage)
await node.click('collapse')
await comfyPage.keyboard.bypass()
await expect(node).toBeCollapsed()
await expect(node).toBeBypassed()
// End transaction
await afterChange(comfyPage)
// Ensure undo reverts both changes
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
})
test('Can nest multiple change transactions without adding undo steps', async ({
comfyPage
}) => {
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
const bypassAndPin = async () => {
await beforeChange(comfyPage)
await comfyPage.keyboard.bypass()
await expect(node).toBeBypassed()
await comfyPage.page.keyboard.press('KeyP')
await comfyPage.nextFrame()
await expect(node).toBePinned()
await afterChange(comfyPage)
}
const collapse = async () => {
await beforeChange(comfyPage)
await node.click('collapse', { moveMouseToEmptyArea: true })
await expect(node).toBeCollapsed()
await afterChange(comfyPage)
}
const multipleChanges = async () => {
await beforeChange(comfyPage)
// Call other actions that uses begin/endChange
await node.click('title')
await collapse()
await bypassAndPin()
await afterChange(comfyPage)
}
await multipleChanges()
await comfyPage.keyboard.undo()
await expect(node).not.toBeBypassed()
await expect(node).not.toBePinned()
await expect(node).not.toBeCollapsed()
await comfyPage.keyboard.redo()
await expect(node).toBeBypassed()
await expect(node).toBePinned()
await expect(node).toBeCollapsed()
})
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.page.evaluate(() => {
window.app!.graph!.extra.foo = 'bar'
})
// Click empty space to trigger a change detection.
await comfyPage.canvasOps.clickEmptySpace()
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
})